본문으로 건너뛰기

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>
reflectlens 구조를 변환하고 재구성합니다.Lens<NewStructure>
map배열 필드를 순회합니다(useFieldArray 사용).R[]
interopReact Hook Form의 control 시스템에 연결합니다.{ control, name }
narrow유니언 타입을 타입 안전하게 좁힙니다.Lens<SubType>
assert타입 축소를 위한 런타임 타입 단언입니다.void
definedlens 타입에서 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 콜백 매개변수:

매개변수타입설명
valueTid를 포함한 현재 필드 값
lensLens<T>현재 배열 항목에 포커스한 lens
indexnumber현재 배열 인덱스
arrayT[]전체 배열
originLensLens<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에 원하는 사용자 정의 기능을 추가할 수 있습니다.