Skip to content

useLens

React Hook Form을 위한 타입 안전한 함수형 렌즈

</> 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):

다음은 모든 렌즈 인스턴스에서 사용할 수 있는 핵심 메서드입니다:

MethodDescriptionReturns
focus특정 필드 경로에 포커스합니다Lens<PathValue>
reflect렌즈 구조를 변환하고 재구성합니다Lens<NewStructure>
map배열 필드를 반복 처리합니다 (useFieldArray와 함께 사용)R[]
interopReact 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>
)
}
TYPESCRIPT SUPPORT

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 }[]> }) {
// ...
}
IMPORTANT

배열 재구조화의 경우, 반드시 단일 항목이 포함된 배열을 템플릿으로 전달해야 합니다.

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 (
<PersonForm
lens={lens.reflect(({ firstName, lastName, ...rest }) => ({
...rest,
name: firstName,
surname: lastName,
}))}
/>
)
}

map

useFieldArray와 통합되어 배열 필드를 순회합니다. 이 메서드를 사용하라면 useFieldArrayfields 속성을 전달해야 합니다.

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:

ParameterTypeDescription
valueT현재 필드 값(id 포함)
lensLens<T>현재 배열 항목에 포커스된 렌즈
indexnumber현재 배열 인덱스
arrayT[]전체 배열
originLensLens<T[]>원본 배열 렌즈

interop

interop 메서드는 렌즈가 내부적으로 사용하는 controlname 속성을 노출하여 React Hook Form과의 자연스러운 통합을 제공합니다. 이를 통해 렌즈를 React Hook Form의 control API에 손쉽게 연결할 수 있습니다.

첫 번째 형태: 객체 반환

첫 번째 형태는 인수 없이 interop()을 호출하는 것으로, React Hook Form을 위한 controlname 속성을 포함하는 객체를 반환합니다:

const { control, name } = lens.interop()
return <input {...control.register(name)} />

두 번째 형태: 콜백 함수

두 번째 형태는 interop에 콜백 함수를 전달하는 방식입니다. 이 콜백은 controlname 속성을 인자로 받아, 콜백 내부에서 직접 이 값들을 활용할 수 있습니다:

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))}
/>
<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>
)
}
RULES
  • 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: 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은 서로 다른 소스와 함께 두 번 사용됩니다.
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: TName
control: 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 OPTIMIZATION

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>
)
}
QUESTIONS OR FEEDBACK?

버그를 발견하셨거나 기능 요청이 있으신가요? GitHub 저장소에서 이슈를 등록하거나 프로젝트에 기여해 주세요.

지원해 주셔서 감사합니다

프로젝트에서 React Hook Form이 유용하다고 생각하신다면, 스타를 눌러 지원해 주시길 부탁드립니다.