useLens
</> useLens
React Hook Form Lenses는 함수형 lens의 우아함을 폼 개발에 도입하는 강력한 TypeScript 우선 라이브러리입니다. 중첩 구조를 타입 안전하게 조작하여 복잡한 데이터를 정확하고 쉽게 제어하고 변환할 수 있습니다.
useLens는 React Hook Form control에 연결된 lens 인스턴스를 생성하는 커스텀 훅입니다. 함수형 프로그래밍 개념을 통해 깊게 중첩된 폼 데이터 구조의 포커스, 변환, 조작을 타입 안전하게 수행할 수 있습니다.
설치
npm install @hookform/lenses
기능
- 타입 안전 폼 상태: 완전한 TypeScript 지원과 정확한 타입 추론으로 데이터의 특정 부분에 포커스합니다.
- 함수형 lens: 조합 가능한 lens 연산으로 복잡한 변환을 구성합니다.
- 깊은 구조 지원: 전용 연산으로 깊게 중첩된 구조와 배열을 우아하게 처리합니다.
- 매끄러운 통합: React Hook Form의 Control API 및 기존 기능과 원활하게 동작합니다.
- 최적화된 성능: 각 lens를 캐시하고 재사용하여 효율을 극대화합니다.
- 배열 처리: 타입 안전 매핑으로 동적 필드를 전용 지원합니다.
- 조합 가능한 API: 우아한 lens 조합으로 복잡한 변환을 구성합니다.
Props
useLens 훅은 다음 설정을 받습니다.
control: Control<TFieldValues>
필수입니다. React Hook Form의 useForm 훅에서 가져온 control 객체입니다. lens를 폼 관리 시스템에 연결합니다.
const { control } = useForm<MyFormData>()
const lens = useLens({ control })
의존성 배열(선택 사항)
선택적으로 의존성 배열을 두 번째 매개변수로 전달할 수 있습니다. 의존성이 변경되면 lens 캐시를 비우고 모든 lens를 다시 생성합니다.
const lens = useLens({ control }, [dependencies])
외부 상태 변경에 따라 전체 lens 캐시를 재설정해야 할 때 유용합니다.
반환값
다음 표는 lens 인스턴스에서 사용할 수 있는 주요 타입과 연산을 설명합니다.
핵심 타입:
Lens<T> - 작업 중인 필드 타입에 따라 여러 연산을 제공하는 주요 lens 타입입니다.
type LensWithArray = Lens<string[]>
type LensWithObject = Lens<{ name: string; age: number }>
type LensWithPrimitive = Lens<string>
주요 연산:
모든 lens 인스턴스에서 사용할 수 있는 핵심 메서드는 다음과 같습니다.
| 메서드 | 설명 | 반환값 |
|---|---|---|
focus | 특정 필드 경로에 포커스합니다. | Lens<PathValue> |
reflect | lens 구조를 변환하고 재구성합니다. | Lens<NewStructure> |
map | 배열 필드를 순회합니다(useFieldArray 사용). | R[] |
interop | React Hook Form의 control 시스템에 연결합니다. | { control, name } |
narrow | 유니언 타입을 타입 안전하게 좁힙니다. | Lens<SubType> |
assert | 타입 축소를 위한 런타임 타입 단언입니다. | void |
defined | lens 타입에서 null과 undefined를 제외합니다. | Lens<NonNullable> |
cast | 타입을 강제로 변경합니다(안전하지 않음). | Lens<NewType> |
focus
특정 경로에 포커스한 새 lens를 생성합니다. 데이터 구조 안으로 내려가는 기본 메서드입니다.
// Type-safe path focusing
const profileLens = lens.focus("profile")
const emailLens = lens.focus("profile.email")
const arrayItemLens = lens.focus("users.0.name")
배열 포커스:
function ContactsList({ lens }: { lens: Lens<Contact[]> }) {
// Focus on specific array index
const firstContact = lens.focus("0")
const secondContactName = lens.focus("1.name")
return (
<div>
<ContactForm lens={firstContact} />
<input
{...secondContactName.interop((ctrl, name) => ctrl.register(name))}
/>
</div>
)
}
reflect
완전한 타입 추론으로 lens 구조를 변환합니다. 기존 lens에서 형태가 다른 새 lens를 만들어 공용 컴포넌트에 전달할 때 유용합니다.
첫 번째 인수는 lens 사전을 가진 proxy입니다. lens 인스턴스는 속성에 접근할 때만 생성됩니다. 두 번째 인수는 원본 lens입니다.
객체 reflection
const contactLens = lens.reflect(({ profile }) => ({
name: profile.focus("contact.firstName"),
phoneNumber: profile.focus("contact.phone"),
}))
<SharedComponent lens={contactLens} />
function SharedComponent({
lens,
}: {
lens: Lens<{ name: string; phoneNumber: string }>
}) {
return (
<div>
<input
{...lens.focus("name").interop((ctrl, name) => ctrl.register(name))}
/>
<input
{...lens
.focus("phoneNumber")
.interop((ctrl, name) => ctrl.register(name))}
/>
</div>
)
}
lens 매개변수를 사용하는 대체 문법:
두 번째 매개변수(원본 lens)를 직접 사용할 수도 있습니다.
const contactLens = lens.reflect((_, l) => ({
name: l.focus("profile.contact.firstName"),
phoneNumber: l.focus("profile.contact.phone"),
}))
<SharedComponent lens={contactLens} />
function SharedComponent({
lens,
}: {
lens: Lens<{ name: string; phoneNumber: string }>
}) {
// ...
}
배열 reflection
배열 lens를 재구성할 수 있습니다.
function ArrayComponent({ lens }: { lens: Lens<{ value: string }[]> }) {
return (
<AnotherComponent lens={lens.reflect(({ value }) => [{ data: value }])} />
)
}
function AnotherComponent({ lens }: { lens: Lens<{ data: string }[]> }) {
// ...
}
lens 병합
reflect를 사용하여 두 lens를 하나로 병합할 수 있습니다.
function Component({
lensA,
lensB,
}: {
lensA: Lens<{ firstName: string }>
lensB: Lens<{ lastName: string }>
}) {
const combined = lensA.reflect((_, l) => ({
firstName: l.focus("firstName"),
lastName: lensB.focus("lastName"),
}))
return <PersonForm lens={combined} />
}
이 경우 reflect에 전달한 함수가 더 이상 순수 함수가 아니라는 점에 유의하세요.
spread 연산자 지원
다른 속성을 그대로 두려면 reflect에서 spread를 사용할 수 있습니다. 런타임에 첫 번째 인수는 원본 lens에서 focus를 호출하는 proxy일 뿐입니다. 일부 필드의 속성 이름만 변경하고 나머지는 유지하면서 올바른 타입을 지정할 때 유용합니다.
function Component({
lens,
}: {
lens: Lens<{ firstName: string; lastName: string; age: number }>
}) {
return (
<PersonForm
lens={lens.reflect(({ firstName, lastName, ...rest }) => ({
...rest,
name: firstName,
surname: lastName,
}))}
/>
)
}
map
useFieldArray와 통합하여 배열 필드를 매핑합니다. 이 메서드에는 fields 속성이 필요하며 useFieldArray에서 가져옵니다.
import { useFieldArray } from "@hookform/lenses/rhf"
function ContactsList({ lens }: { lens: Lens<Contact[]> }) {
const { fields, append, remove } = useFieldArray(lens.interop())
return (
<div>
<button onClick={() => append({ name: "", email: "" })}>
Add Contact
</button>
{lens.map(fields, (value, l, index) => (
<div key={value.id}>
<button onClick={() => remove(index)}>Remove</button>
<ContactForm lens={l} />
</div>
))}
</div>
)
}
function ContactForm({
lens,
}: {
lens: Lens<{ name: string; email: string }>
}) {
return (
<div>
<input
{...lens.focus("name").interop((ctrl, name) => ctrl.register(name))}
/>
<input
{...lens.focus("email").interop((ctrl, name) => ctrl.register(name))}
/>
</div>
)
}
map 콜백 매개변수:
| 매개변수 | 타입 | 설명 |
|---|---|---|
value | T | id를 포함한 현재 필드 값 |
lens | Lens<T> | 현재 배열 항목에 포커스한 lens |
index | number | 현재 배열 인덱스 |
array | T[] | 전체 배열 |
originLens | Lens<T[]> | 원본 배열 lens |
interop
interop 메서드는 내부 control 및 name 속성을 노출하여 React Hook Form과 매끄럽게 통합합니다. 이를 통해 lens를 React Hook Form의 control API에 연결할 수 있습니다.
첫 번째 변형: 객체 반환
첫 번째 변형은 인수 없이 interop()를 호출하며 React Hook Form의 control과 name 속성을 포함한 객체를 반환합니다.
const { control, name } = lens.interop()
return <input {...control.register(name)} />
두 번째 변형: 콜백 함수
두 번째 변형은 콜백 함수를 interop에 전달합니다. 이 함수는 control과 name 속성을 인수로 받으므로 콜백 범위 안에서 속성을 직접 사용할 수 있습니다.
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...lens.interop((ctrl, name) => ctrl.register(name))} />
<input type="submit" />
</form>
)
useController와 통합
interop 메서드의 반환값을 React Hook Form의 useController 훅에 직접 전달하여 매끄럽게 통합할 수 있습니다.
import { useController } from "react-hook-form"
function ControlledInput({ lens }: { lens: Lens<string> }) {
const { field, fieldState } = useController(lens.interop())
return (
<div>
<input {...field} />
{fieldState.error && <p>{fieldState.error.message}</p>}
</div>
)
}
narrow
narrow 메서드는 유니언 타입을 타입 안전하게 좁혀 타입 시스템에 작업할 유니언 분기를 알려줍니다. 판별 유니언이나 선택적 값을 다룰 때 특히 유용합니다.
수동 타입 축소
외부 로직으로 값의 타입을 알고 있다면 단일 제네릭 매개변수를 사용하여 타입을 수동으로 좁힙니다.
// Lens<string | number>
const unionLens = lens.focus("optionalField")
// Narrow to string when you know it's a string
const stringLens = unionLens.narrow<string>()
// Now: Lens<string>
판별 유니언 축소
판별자 오버로드를 사용하여 특정 속성 값을 기준으로 타입을 좁힙니다.
type Animal = { type: "dog"; breed: string } | { type: "cat"; indoor: boolean }
const animalLens: Lens<Animal> = lens.focus("pet")
// Narrow to Dog type using discriminant
const dogLens = animalLens.narrow("type", "dog")
// Now: Lens<{ type: 'dog'; breed: string }>
const breedLens = dogLens.focus("breed")
// Type-safe access to dog-specific properties
assert
assert 메서드는 현재 lens가 이미 원하는 하위 타입이라고 TypeScript에 알리는 런타임 타입 단언을 제공합니다. narrow와 달리 현재 lens 인스턴스를 수정하는 타입 단언입니다.
수동 타입 단언
제네릭 매개변수를 사용하여 lens가 이미 원하는 타입이라고 단언합니다.
function processString(lens: Lens<string>) {
// Work with string lens
}
const maybeLens: Lens<string | undefined> = lens.focus("optional")
// After your runtime check
if (value !== undefined) {
maybeLens.assert<string>()
processString(maybeLens) // Now TypeScript knows it's Lens<string>
}
판별자 기반 단언
조건부 분기 안에서는 판별자 오버로드를 사용합니다.
type Status =
| { type: "loading" }
| { type: "success"; data: string }
| { type: "error"; message: string }
const statusLens: Lens<Status> = lens.focus("status")
// In a conditional branch
if (selected.type === "success") {
statusLens.assert("type", "success")
// Within this block, statusLens is Lens<{ type: 'success'; data: string }>
const dataLens = statusLens.focus("data") // Type-safe access
}
defined
defined 메서드는 lens 타입에서 null과 undefined 값을 제외하도록 좁히는 편의 함수입니다. narrow<NonNullable<T>>()를 사용하는 것과 같지만 더 표현력 있는 API를 제공합니다.
const optionalLens: Lens<string | null | undefined> = lens.focus("optional")
// Remove null and undefined from the type
const definedLens = optionalLens.defined()
// Now: Lens<string>
// Use after validation
if (value != null) {
const safeLens = optionalLens.defined()
// Work with guaranteed non-null value
}
일반적인 사용 사례:
// Form validation
const emailLens = lens.focus("email") // Lens<string | undefined>
function validateEmail(email: string) {
// validation logic
}
// After confirming value exists
if (formState.isValid) {
const validEmailLens = emailLens.defined()
// Pass to functions expecting non-null values
validateEmail(validEmailLens.interop().control.getValues())
}
cast
cast 메서드는 원본 타입과의 호환성과 관계없이 lens 타입을 새 타입으로 강제 변경합니다. 강력하지만 잠재적으로 안전하지 않은 연산이므로 매우 신중하게 사용해야 합니다.
// Cast from unknown/any to specific type
const unknownLens: Lens<unknown> = lens.focus("dynamicData")
const stringLens = unknownLens.cast<string>()
// Now: Lens<string>
// Cast between incompatible types (dangerous!)
const numberLens: Lens<number> = lens.focus("count")
const stringLens = numberLens.cast<string>()
// Type system now thinks it's Lens<string>, but runtime value is still number
안전한 사용 패턴:
// Working with external APIs returning 'any'
function processApiData(data: any) {
const apiLens = LensCore.create(data)
// Cast after runtime validation
if (typeof data.user === "object" && data.user !== null) {
const userLens = apiLens.focus("user").cast<User>()
return <UserProfile lens={userLens} />
}
}
// Type narrowing when you have more information
interface BaseConfig {
type: string
}
interface DatabaseConfig extends BaseConfig {
type: "database"
connectionString: string
}
const configLens: Lens<BaseConfig> = lens.focus("config")
// After checking the type at runtime
if (config.type === "database") {
const dbConfigLens = configLens.cast<DatabaseConfig>()
// Now can access database-specific properties
}
useFieldArray
향상된 useFieldArray를 @hookform/lenses/rhf에서 가져오면 lens로 배열을 매끄럽게 처리할 수 있습니다.
import { useFieldArray } from "@hookform/lenses/rhf"
function DynamicForm({
lens,
}: {
lens: Lens<{ items: { name: string; value: number }[] }>
}) {
const itemsLens = lens.focus("items")
const { fields, append, remove, move } = useFieldArray(itemsLens.interop())
return (
<div>
<button onClick={() => append({ name: "", value: 0 })}>Add Item</button>
{itemsLens.map(fields, (field, itemLens, index) => (
<div key={field.id}>
<input
{...itemLens
.focus("name")
.interop((ctrl, name) => ctrl.register(name))}
/>
<input
type="number"
{...itemLens
.focus("value")
.interop((ctrl, name) =>
ctrl.register(name, { valueAsNumber: true })
)}
/>
<button onClick={() => remove(index)}>Remove</button>
{index > 0 && (
<button onClick={() => move(index, index - 1)}>Move Up</button>
)}
</div>
))}
</div>
)
}
예제
기본 사용법
import { useForm } from "react-hook-form"
import { Lens, useLens } from "@hookform/lenses"
import { useFieldArray } from "@hookform/lenses/rhf"
function FormComponent() {
const { handleSubmit, control } = useForm<{
firstName: string
lastName: string
children: {
name: string
surname: string
}[]
}>({})
const lens = useLens({ control })
return (
<form onSubmit={handleSubmit(console.log)}>
<PersonForm
lens={lens.reflect(({ firstName, lastName }) => ({
name: firstName,
surname: lastName,
}))}
/>
<ChildForm lens={lens.focus("children")} />
<input type="submit" />
</form>
)
}
function ChildForm({
lens,
}: {
lens: Lens<{ name: string; surname: string }[]>
}) {
const { fields, append } = useFieldArray(lens.interop())
return (
<>
<button type="button" onClick={() => append({ name: "", surname: "" })}>
Add child
</button>
{lens.map(fields, (value, l) => (
<PersonForm key={value.id} lens={l} />
))}
</>
)
}
// PersonForm is used twice with different sources
function PersonForm({
lens,
}: {
lens: Lens<{ name: string; surname: string }>
}) {
return (
<div>
<StringInput lens={lens.focus("name")} />
<StringInput lens={lens.focus("surname")} />
</div>
)
}
function StringInput({ lens }: { lens: Lens<string> }) {
return <input {...lens.interop((ctrl, name) => ctrl.register(name))} />
}
도입 배경
React Hook Form에서 복잡하고 깊게 중첩된 폼을 다루면 금세 어려워질 수 있습니다. 기존 방식은 개발을 더 어렵게 하고 오류 가능성을 높이는 다음과 같은 일반적인 문제를 자주 일으킵니다.
1. 타입 안전한 name prop 구현이 거의 불가능함
재사용 가능한 폼 컴포넌트를 만들려면 제어할 필드를 지정하는 name prop을 받아야 합니다. 하지만 이를 TypeScript에서 타입 안전하게 만드는 것은 매우 어렵습니다.
// ❌ Loses type safety - no way to ensure name matches the form schema
interface InputProps<T> {
name: string // Could be any string, even invalid field paths
control: Control<T>
}
// ❌ Attempting proper typing leads to complex, unmaintainable generics
interface InputProps<T, TName extends Path<T>> {
name: TName
control: Control<T>
}
// This becomes unwieldy and breaks down with nested objects
2. useFormContext()가 강한 결합을 만듦
재사용 가능한 컴포넌트에서 useFormContext()를 사용하면 특정 폼 스키마와 강하게 결합되어 이식성이 낮아지고 공유하기 어려워집니다.
// ❌ Tightly coupled to parent form structure
function AddressForm() {
const { control } = useFormContext<UserForm>() // Locked to UserForm type
return (
<div>
<input {...control.register("address.street")} />{" "}
{/* Fixed field paths */}
<input {...control.register("address.city")} />
</div>
)
}
// Can't reuse this component with different form schemas
3. 문자열 기반 필드 경로는 오류가 발생하기 쉬움
필드 경로를 문자열로 이어 붙여 재사용 가능한 컴포넌트를 만들면 취약하고 유지보수하기 어렵습니다.
// ❌ String concatenation is error-prone and hard to refactor
function PersonForm({ basePath }: { basePath: string }) {
const { register } = useForm();
return (
<div>
{/* No type safety, prone to typos */}
<input {...register(`${basePath}.firstName`)} />
<input {...register(`${basePath}.lastName`)} />
<input {...register(`${basePath}.email`)} />
</div>
);
}
// Usage becomes unwieldy and error-prone
<PersonForm basePath="user.profile.owner" />
<PersonForm basePath="user.profile.emergency_contact" />
성능 최적화
내장 캐싱 시스템
React.memo를 사용할 때 불필요한 컴포넌트 리렌더링을 방지하도록 lens가 자동으로 캐시됩니다. 따라서 같은 경로에 여러 번 포커스하면 동일한 lens 인스턴스를 반환합니다.
assert(lens.focus("firstName") === lens.focus("firstName"))
함수 메모이제이션
reflect 같은 메서드에 함수를 사용할 때는 캐싱 이점을 유지하도록 함수의 동일성에 주의해야 합니다.
// ❌ Creates a new function on every render, breaking the cache
lens.reflect((proxy) => proxy.focus("firstName"))
캐싱을 유지하려면 전달하는 함수를 메모이제이션합니다.
// ✅ Memoized function preserves the cache
lens.reflect(useCallback((proxy) => proxy.focus("firstName"), []))
고급 사용법
lens 수동 생성
고급 사용 사례나 더 세밀한 제어가 필요할 때는 useLens 훅 없이 LensCore 클래스를 사용하여 lens를 수동으로 생성할 수 있습니다.
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { LensCore, LensesStorage } from "@hookform/lenses"
function App() {
const { control } = useForm<{ firstName: string; lastName: string }>()
const lens = useMemo(() => {
const cache = new LensesStorage(control)
return LensCore.create(control, cache)
}, [control])
return (
<div>
<input
{...lens
.focus("firstName")
.interop((ctrl, name) => ctrl.register(name))}
/>
<input
{...lens.focus("lastName").interop((ctrl, name) => ctrl.register(name))}
/>
</div>
)
}
lens 확장
LensBase 인터페이스에 사용자 정의 메서드를 추가하여 기본 lens 기능을 확장할 수 있습니다. 기본 lens API에 없는 메서드가 추가로 필요할 때 유용합니다.
예를 들어 현재 폼 값을 쉽게 가져오는 getValue 메서드를 lens에 추가해 보겠습니다.
1단계: 타입 선언 파일 생성
lenses.d.ts 파일을 생성하여 원하는 메서드로 기본 인터페이스를 확장합니다.
declare module "@hookform/lenses" {
interface LensBase<T> {
getValue(): T
}
}
export {}
2단계: 사용자 정의 lens core 구현 생성
실제 런타임 구현을 담은 MyLensCore.ts 파일을 생성합니다.
import type { FieldValues } from "react-hook-form"
import { LensCore } from "@hookform/lenses"
export class MyLensCore<T extends FieldValues> extends LensCore<T> {
public getValue() {
return this.control._formValues
}
}
3단계: 커스텀 훅 생성
control을 받아 일반적인 방식으로 lens를 반환하는 useMyLens.ts 파일을 생성합니다.
import { type DependencyList, useMemo } from "react"
import type { FieldValues } from "react-hook-form"
import { LensesStorage, type Lens, type UseLensProps } from "@hookform/lenses"
import { MyLensCore } from "./MyLensCore"
export function useMyLens<TFieldValues extends FieldValues = FieldValues>(
props: UseLensProps<TFieldValues>,
deps: DependencyList = []
): Lens<TFieldValues> {
return useMemo(() => {
const cache = new LensesStorage(props.control)
const lens = new MyLensCore<TFieldValues>(
props.control,
"",
cache
) as unknown as Lens<TFieldValues>
return lens
}, [props.control, ...deps])
}
4단계: 확장된 lens 사용
이제 이 훅을 평소처럼 사용할 수 있으며 새 메서드에도 올바른 TypeScript 지원이 적용됩니다.
const { control } = useForm()
const lens = useMyLens({ control })
lens.getValue() // Your custom method is now available with full type support
이 패턴을 사용하면 완전한 타입 안전성과 기존 lens API와의 호환성을 유지하면서 lens에 원하는 사용자 정의 기능을 추가할 수 있습니다.