</> useLens
React Hook Form Lenses는 함수형 렌즈의 개념을 폼 개발에 적용한 TypeScript 우선 라이브러리입니다. 중첩된 구조를 타입 안전하게 다룰 수 있어, 복잡한 데이터를 쉽고 정밀하게 제어하고 변환할 수 있습니다.
useLens
는 React Hook Form의 control에 연결된 렌즈 인스턴스를 생성하는 커스텀 훅으로, 함수형 프로그래밍 개념을 통해 깊이 중첩된 폼 데이터 구조를 타입 안전하게 포커싱, 변환, 조작할 수 있게 해줍니다.
설치
npm install @hookform/lenses
기능
- 타입 안전한 폼 상태: 완전한 TypeScript 지원과 정밀한 타입 추론으로 데이터의 핵심 부분에 집중하세요.
- 함수형 렌즈: 렌즈 연산을 조합하여 복잡한 변환을 손쉽게 구축할 수 있습니다.
- 깊은 구조 지원: 중첩된 구조와 배열을 특화된 연산으로 우아하게 다룰 수 있습니다.
- 매끄러운 통합: React Hook Form의 Control API 및 기존 기능과 자연스럽게 연동됩니다.
- 최적화된 성능: 각 렌즈는 캐시되어 재사용되므로 효율성이 극대화됩니다.
- 배열 처리: 타입 안전한 매핑으로 동적 필드를 손쉽게 지원합니다.
- 조합 가능한 API: 우아한 렌즈 조합으로 복잡한 변환을 구현할 수 있습니다.
Props
useLens
훅은 다음과 같은 설정을 받습니다:
control
: Control<TFieldValues>
필수. React Hook Form의 useForm
훅에서 반환된 control 객체입니다. 이 객체를 통해 렌즈가 폼 관리 시스템과 연결됩니다.
const { control } = useForm<MyFormData>()const lens = useLens({ control })
의존성 배열(Dependencies Array) (선택 사항)
옵션으로 두 번째 인자로 의존성 배열(Dependencies Array)를 전달할 수 있습니다. 이 배열에 포함된 값이 변경되면 렌즈 캐시가 모두 초기화되고, 모든 렌즈가 새로 생성됩니다:
const lens = useLens({ control }, [dependencies])
외부 상태 변경에 따라 전체 렌즈 캐시를 초기화해야 할 때 유용합니다.
반환값
다음 표는 렌즈 인스턴스에서 사용할 수 있는 주요 타입과 연산에 대한 정보를 담고 있습니다:
핵심 타입(Core Types):
Lens<T>
- 작업 중인 필드 타입에 따라 다양한 연산을 제공하는 주요 렌즈 타입입니다:
type LensWithArray = Lens<string[]>type LensWithObject = Lens<{ name: string; age: number }>type LensWithPrimitive = Lens<string>
주요 연산(Main Operations):
다음은 모든 렌즈 인스턴스에서 사용할 수 있는 핵심 메서드입니다:
Method | Description | Returns |
---|---|---|
focus | 특정 필드 경로에 포커스합니다 | Lens<PathValue> |
reflect | 렌즈 구조를 변환하고 재구성합니다 | Lens<NewStructure> |
map | 배열 필드를 반복 처리합니다 (useFieldArray와 함께 사용) | R[] |
interop | React Hook Form의 control 시스템과 연결합니다 | { control, name } |
focus
특정 경로에 포커스된 새로운 렌즈를 생성합니다. 데이터 구조를 깊이 탐색할 때 사용하는 주요 메서드입니다.
// 타입 안전한 경로 포커싱const profileLens = lens.focus("profile")const emailLens = lens.focus("profile.email")const arrayItemLens = lens.focus("users.0.name")
배열 포커싱:
function ContactsList({ lens }: { lens: Lens<Contact[]> }) {// 특정 배열 인덱스에 포커스하기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>)}
focus
메서드는 TypeScript의 자동 완성과 타입 검사를 완벽 지원합니다:
- 사용 가능한 필드 경로 자동완성
- 존재하지 않는 경로에 대한 타입 에러
- 포커싱된 필드에 따라 추론된 반환 타입
reflect
렌즈 구조를 완전한 타입 추론과 함께 변환합니다. 기존 렌즈에서 새로운 형태의 렌즈를 만들어 공통 컴포넌트에 전달할 때 유용합니다.
첫 번째 인자는 렌즈의 딕셔너리 프록시입니다. 프로퍼티에 접근할 때만 실제 렌즈가 생성됩니다. 두 번째 인자는 원본 렌즈입니다.
Object 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 매개변수를 직접 사용하는 대체 문법:
두 번째 매개변수(원본 렌즈)를 직접 사용할 수도 있습니다:
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 }>}) {// ...}
배열 재구조화(Array Reflection)
배열 렌즈도 구조를 재구성할 수 있습니다:
function ArrayComponent({ lens }: { lens: Lens<{ value: string }[]> }) {return (<AnotherComponent lens={lens.reflect(({ value }) => [{ data: value }])} />)}function AnotherComponent({ lens }: { lens: Lens<{ data: string }[]> }) {// ...}
배열 재구조화의 경우, 반드시 단일 항목이 포함된 배열을 템플릿으로 전달해야 합니다.
Merging Lenses
reflect
를 사용하여 두 개의 렌즈를 하나로 병합할 수 있습니다:
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 Operator Support
reflect에서 스프레드 연산자를 사용하면 나머지 프로퍼티를 그대로 유지할 수 있습니다. 런타임에서 첫 번째 인자는 원본 렌즈의 focus
를 호출하는 프록시일 뿐입니다. 일부 필드의 프로퍼티 이름만 변경하고 나머지는 그대로 두고 싶을 때 타입 안전하게 사용할 수 있습니다:
function Component({lens,}: {lens: Lens<{ firstName: string; lastName: string; age: number }>}) {return (<PersonFormlens={lens.reflect(({ firstName, lastName, ...rest }) => ({...rest,name: firstName,surname: lastName,}))}/>)}
map
useFieldArray
와 통합되어 배열 필드를 순회합니다. 이 메서드를 사용하라면 useFieldArray
의 fields
속성을 전달해야 합니다.
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 callback parameters:
Parameter | Type | Description |
---|---|---|
value | T | 현재 필드 값(id 포함) |
lens | Lens<T> | 현재 배열 항목에 포커스된 렌즈 |
index | number | 현재 배열 인덱스 |
array | T[] | 전체 배열 |
originLens | Lens<T[]> | 원본 배열 렌즈 |
interop
interop
메서드는 렌즈가 내부적으로 사용하는 control
과 name
속성을 노출하여 React Hook Form과의 자연스러운 통합을 제공합니다. 이를 통해 렌즈를 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>)}
useFieldArray
렌즈와 함께 배열을 손쉽게 다루려면 @hookform/lenses/rhf
에서 확장된 useFieldArray
를 import하세요.
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))}/><inputtype="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>)}
control
파라미터는 필수이며, React Hook Form의useForm
훅에서 반환된 객체여야 합니다.- 각 렌즈는 성능 최적화를 위해 캐시에 저장되어 재사용되며, 동일한 경로를 여러 번 포커스하더라도 항상 동일한 렌즈 인스턴스가 반환됩니다.
reflect
등 함수형 메서드에 함수를 전달할 때는 캐싱 효과를 유지하려면 해당 함수를 메모이제이션하세요.- 의존성 배열(Dependencies Array)은 선택 사항이지만, 외부 상태 변화에 따라 렌즈 캐시를 초기화할 때 유용합니다.
- 모든 렌즈 연산은 TypeScript 타입 안전성과 타입 추론을 완벽히 보장합니다.
예제
기본 사용법
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: stringlastName: stringchildren: {name: stringsurname: string}[]}>({})const lens = useLens({ control })return (<form onSubmit={handleSubmit(console.log)}><PersonFormlens={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은 서로 다른 소스와 함께 두 번 사용됩니다.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 프롭은 사실상 불가능합니다
재사용 가능한 폼 컴포넌트를 만들려면 제어할 필드를 지정하기 위해 name
프롭을 받아야 합니다. 그러나 TypeScript에서 이를 타입 안전하게 만드는 것은 매우 어렵습니다:
// ❌ 타입 안전성을 잃음 - name이 폼 스키마와 일치하는지 확인할 방법이 없음interface InputProps<T> {name: string // 임의의 문자열일 수 있으며, 잘못된 필드 경로일 수도 있음control: Control<T>}// ❌ 적절한 타입 지정을 시도하면 복잡하고 유지 관리가 어려운 제네릭으로 이어집니다.interface InputProps<T, TName extends Path<T>> {name: TNamecontrol: Control<T>}// 중첩 객체에서는 유지보수가 어렵고 쉽게 무너집니다
2. useFormContext()
는 강한 결합을 만듭니다
useFormContext()
를 재사용 가능한 컴포넌트에서 사용하면 특정 폼 스키마에 강하게 결합되어 유연성이 떨어지고 공유하기 어려워집니다:
// ❌ 부모 폼 구조에 강하게 결합됨function AddressForm() {const { control } = useFormContext<UserForm>() // UserForm 타입에 고정됨return (<div><input {...control.register("address.street")} /> {/* 고정된 필드 경로 */}<input {...control.register("address.city")} /></div>)}// 다른 폼 스키마로 이 컴포넌트를 재사용할 수 없음
3. 문자열 기반 필드 경로는 오류를 유발하기 쉽습니다
문자열 연결로 필드 경로를 만드는 재사용 가능한 컴포넌트 방식은 매우 취약하고 유지보수가 어렵습니다:
// ❌ 문자열 연결 방식은 오류가 발생하기 쉽고 리팩터링이 어렵습니다function PersonForm({ basePath }: { basePath: string }) {const { register } = useForm();return (<div>{/* 타입 안전성이 없고, 오타에 취약합니다 */}<input {...register(`${basePath}.firstName`)} /><input {...register(`${basePath}.lastName`)} /><input {...register(`${basePath}.email`)} /></div>);}// 사용법이 번거롭고 오류가 발생하기 쉽습니다<PersonForm basePath="user.profile.owner" /><PersonForm basePath="user.profile.emergency_contact" />
성능 최적화
내장 캐싱 시스템
렌즈는 React.memo
를 사용할 때 불필요한 컴포넌트 리렌더링을 방지하기 위해 자동으로 캐싱됩니다. 즉, 동일한 경로에 여러 번 포커스해도 항상 동일한 렌즈 인스턴스가 반환됩니다:
assert(lens.focus("firstName") === lens.focus("firstName"))
함수 메모이제이션
reflect
와 같은 메서드에 함수를 전달할 때는 캐싱 효과를 유지하려면 함수의 동일성에 주의해야 합니다:
// ❌ 매 렌더마다 새로운 함수를 생성하여 캐시가 깨집니다lens.reflect((l) => l.focus("firstName"))
캐싱을 유지하려면, 전달하는 함수를 반드시 메모이제이션하세요:
// ✅ 메모이제이션된 함수는 캐시를 보존합니다lens.reflect(useCallback((l) => l.focus("firstName"), []))
React Compiler는 이러한 함수들을 자동으로 최적화해줍니다! reflect
에 전달되는 함수는 부작용이 없으므로, React Compiler가 자동으로 해당 함수를 모듈 스코프로 끌어올려 렌즈 캐싱이 수동 메모이제이션 없이도 완벽하게 동작하도록 보장합니다.
고급 사용법
수동 렌즈 생성
더 고급 사용 사례나 세밀한 제어가 필요할 때는 useLens
훅 없이 LensCore
클래스를 사용해 렌즈를 직접 생성할 수 있습니다:
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>)}
버그를 발견하셨거나 기능 요청이 있으신가요? GitHub 저장소에서 이슈를 등록하거나 프로젝트에 기여해 주세요.
지원해 주셔서 감사합니다
프로젝트에서 React Hook Form이 유용하다고 생각하신다면, 스타를 눌러 지원해 주시길 부탁드립니다.