사툰사툰

useLens 파헤치기 : 강결합된 react-hook-form 구조를 Type-Safe하게 재사용하기
REACT

useLens 파헤치기 : 강결합된 react-hook-form 구조를 Type-Safe하게 재사용하기

React Hook Form 컴포넌트의 강결합 문제를 해결하고 타입‑안전하게 재사용할 수 있는 방법을 소개합니다. useLens, focus, interop, reflect, defined 등 Lens 기반 기능을 활용해 폼 경로를 추상화하고, 다양한 스키마와 배열에서도 일관된 컴포넌트 재사용을 구현하는 방법을 설명합니다.

2026. 7. 7.

4

🤦 쓰다보면 느끼는 강결합 문제

react-hook-form 기반의 특정 폼 컴포넌트를 여러 군데에서 재사용하려다 자주 막힌 경험이 있습니다.

// ❌ 컴포넌트 내부에서 폼 경로를 문자열로 직접 꺼내 쓴다
function CategoryField({ disabled }: { disabled: boolean }) {
  const { control } = useFormContext();

  const { field: { value: group, onChange: onGroupChange } } =
    useController({ control, name: 'category.group' });
  const { field: { value: name, onChange: onNameChange } } =
    useController({ control, name: 'category.name' });

  // ...select 렌더링
}

가장 대표적인 예시를 가져왔습니다. CategoryField는 props를 하나도 받지 않습니다. useFormContext()로 가장 가까운 FormProvider에서 control을 꺼내 오고, 'category.group'/'category.name'이라는 경로를 컴포넌트 안에 그대로 사용합니다. 도메인에 맞게 구현한다면 현재로선 무난하게 사용할 수 있지만, 문제는 다른 폼에 가져다 쓰기 힘든 구조가 된다는 점입니다.

  • 다른 폼에 이 컴포넌트를 그대로 사용하면, 그 폼도 필드 이름이 정확히 category.group/category.name이어야 합니다. 만약 이름이 다르면 폼 내부에선 undefined로 등록되며, 런타임까지 가야 오류를 확실히 잡을 수 있습니다.
  • Context 구조상 useFormContext()는 가장 가까운 부모의 FormProvider를 읽기 때문에, 이 컴포넌트가 지금 어떤 구조로 가진 폼 안에 들어와 있는지 컴포넌트 코드만 봐서는 알 수 없습니다.

props로 인터페이스 잡기

컴포넌트의 확실한 인터페이스를 잡으려면 props 기반으로 잡아야 합니다. 그래서 보통 다음과 같은 구조를 생각할 수 있습니다.

자식 컴포넌트에 Path 주입하기

// 🤔 prop로 path를 넘기면?
function CategoryField({ namePrefix }: { namePrefix: string }) {
const { control } = useFormContext();

const { field: groupField } = useController({ control, name: `${namePrefix}.group` });
const { field: nameField } = useController({ control, name: `${namePrefix}.name` });

// ...select 렌더링
}

<CategoryField namePrefix="category" />;

재사용은 됩니다. 어떤 폼이든 namePrefix만 그 폼에 맞게 넘기면 동작합니다. 문제는 namePrefix가 그냥 문자열이라는 점입니다. categoy처럼 오타가 나도 컴파일은 통과하고, 부모 폼의 필드 이름이 바뀌어도 호출부에서 직접 수정해야 합니다. 결합도는 그대로입니다.

// 🤔 prop로 path 자체를 넘기면?
type FormInputProps<TFormValues extends FieldValues> = {
  name: Path<TFormValues>;
  register: UseFormRegister<TFormValues>;
} & Omit<InputProps, 'name'>;

function FormInput<TFormValues extends FieldValues>({
  name,
  register,
  ...rest
}: FormInputProps<TFormValues>) {
  return <input {...register(name)} {...rest} />;
}

<FormInput<FormAType> name="employees.0.first_name" register={formARegister} />

useFormregister 함수와 폼 경로를 같이 props에 내려주어 내부에서 조합하는 컴포넌트 구조라면 컴파일 단계에서 타입 체크가 가능합니다. 이렇게 하면 name 기반으로 다른 타입을 열어둘 수 있습니다.

그런데 이것도 완전한 해답은 아닙니다. CategoryField처럼 필드 객체를 하나로 재사용하려면, Path<TFormValues>를 필드 개수만큼 각각 props로 따로 넘겨야 하며, 인터페이스의 복잡도가 커지거나 필드명이 달라진다면 여전히 같은 문제를 야기합니다.

의존성을 제거한 제어 컴포넌트

// 🤔 아예 값과 핸들러를 그대로 받으면?
interface CategoryFieldProps {
  group: string;
  name: string;
  onGroupChange: (value: string) => void;
  onNameChange: (value: string) => void;
}

function CategoryField({ group, name, onGroupChange, onNameChange }: CategoryFieldProps) {
  return (
    <>
      <input value={group} onChange={(e) => onGroupChange(e.target.value)} />
      <input value={name} onChange={(e) => onNameChange(e.target.value)} />
    </>
  );
}

react-hook-form과의 의존성이 없어지는 대신 재사용은 완벽하게 됩니다. 대신 register가 챙겨주던 비제어 상태 관리나 useController 기반의 제어 상태 같은 이점을 전부 손으로 다시 구현해야 합니다.

제네릭을 활용하면 어떨까?

const categoryFormSchema = z.object({
  category: z.object({ group: z.string(), name: z.string() }),
});
type CategoryFormValues = z.infer<typeof categoryFormSchema>;

function ParentForm() {
  const form = useForm<CategoryFormValues>({ resolver: zodResolver(categoryFormSchema) });
  return (
    <FormProvider {...form}>
      <CategoryField />
    </FormProvider>
  );
}

function CategoryField() {
  const { control } = useFormContext<CategoryFormValues>(); // 스키마에서 뽑은 타입
  const { field: groupField } = useController({ control, name: 'category.group' });
  const { field: nameField } = useController({ control, name: 'category.name' });
  // ...
}

zod로 스키마를 먼저 정의하고, 거기서 뽑은 타입을 useFormuseFormContext 양쪽에 똑같이 적용하면 꽤 그럴듯해 보입니다. 스키마와 타입이 어긋날 일은 없습니다. 그런데 이 방식은 "폼 데이터의 정합성"이지 재사용 문제가 아닙니다.

  • CategoryFormValues는 여전히 하나의 구체적인 타입입니다. 새 폼의 스키마가 category.group이 아니라 profile.categoryGroup와 같은 전혀 다른 인터페이스면 CategoryField는 여전히 못 씁니다. 오히려 새 폼 쪽 스키마를 CategoryField에 맞춰 끼워 넣어야 합니다.
  • useFormContext<CategoryFormValues>()의 제네릭은 상위 컴포넌트에서 제공하는 Form Provider가 이 타입일 거야라는 가정이 담긴 선언일 뿐입니다. 실제로 그 Provider가 그 타입인지는 런타임은 물론 컴파일 타임에도 검증되지 않습니다. 만약 CategoryField를 다른 FormProvider 자식 컴포넌트로 배치해도 타입 에러는 발생하지 않습니다.

결국 재사용할 때마다 똑같은 구조의 컴포넌트를 다시 구현하는 경우가 종종 생깁니다.

여기서의 핵심은 useForm에 의존적이지 않은 폼 경로와 경로 기반의 폼 상태 제어 기능을 동시에 다룰 수 있어야 100% 재사용을 할 수 있다는 점입니다. 지금 구조로는 부모의 useForm 인터페이스를 알아야 하거나 폼에서 주입하는 스키마 구조를 강제로 따라야 합니다.

🔍 useLens로 해결하기

이러한 문제를 해결할 수 있는 도구가 있습니다. 바로 @hookform/lensesuseLens를 활용하면 가능합니다. 컴포넌트에서 Lens라는 데이터 단위를 받기만 하면 됩니다.

import { useLens, Lens } from '@hookform/lenses';

function SettingsForm() {
  const { control } = useForm<FormValues>();
  const lens = useLens({ control }); // Lens<FormValues>
  const categoryLens = lens.focus('category'); // Lens<{ group: string; name: string }>

  return <CategoryField categoryLens={categoryLens} />;
}

// ✅ 컴포넌트는 lens만 받는다 — 어떤 폼인지 몰라도 된다
function CategoryField({ categoryLens }: { categoryLens: Lens<{ group: string; name: string }> }) {
  const { field: groupField } = useController(categoryLens.focus('group').interop());
  const { field: nameField } = useController(categoryLens.focus('name').interop());

  return (
    <>
      <input value={groupField.value} onChange={groupField.onChange} />
      <input value={nameField.value} onChange={nameField.onChange} />
    </>
  );
}

해당 구조로 바꾸면 이제 CategoryFieldcategory라는 이름도, 자기가 어떤 폼 안에 있는지도 모릅니다. 부모가 어떤 Lens를 넘겨주느냐에만 의존합니다. 컴포넌트 안에서만 결합도를 가지고, Lens 기반으로 독립적으로 동작하기 때문에 어떤 폼에서든 그대로 재사용됩니다.

Lens는 값이 아니라 데이터의 특정 부분을 가리키는 참조 객체입니다. 카메라 렌즈가 한 곳에 초점을 맞추듯, 폼 전체 데이터 중 특정 필드에 초점을 맞춘 참조 객체를 만듭니다. 어떤 기능이 구체적으로 있는지 예시 기반으로 들여다보겠습니다.

렌즈 고정 (focus)

예시를 보면 categoryLensgroupname을 포함한 category 객체 전체를 가리킵니다. 해당 값으로 groupname을 수정하는 두 개의 input이 있다고 가정해보겠습니다. 각 <input>에선 객체 전체를 알고 있을 필요가 없습니다. group을 수정하는 <input>이라면 group 값 하나만 알면 됩니다. name까지 같이 들고 있을 이유가 없습니다. 그렇다면 객체 전체가 아닌 특정 필드를 좁히는 Lens가 따로 필요한데, 그 좁히기를 담당하는 게 focus입니다.

categoryLens.focus('group');          // Lens<string>
categoryLens.focus('some.nested');    // 점 경로 OK
lens.focus('items').focus(0);         // 배열은 숫자 인덱스

존재하지 않는 키를 넣으면 컴파일 에러가 발생합니다. 좁혀도 타입 추론은 보장됩니다.

렌즈 변환 (interop)

그런데 focus로 좁힌 Lens를 그대로 <input>이나 useController에 바로 주입할 순 없습니다. 실제로 상태 관리에 필요한 함수는 제어형인 control과 비제어형인 name이지 Lens가 아니기 때문입니다. 그 변환을 담당하는 게 interop입니다.

// 콜백형 — register로 연결
<input {...categoryLens.focus('group').interop((ctrl, name) => ctrl.register(name))} />;

// 무인자형 — { control, name } 반환 (Controller·useFieldArray 등에)
const { control, name } = categoryLens.focus('group').interop();

Lens는 의도적으로 값을 읽거나 수정하는 getter/setter를 제공하지 않습니다.

const type = useWatch({ control, name: 'type' });

// 값은 useWatch로 읽어 조건부 렌더에 쓰고, 편집은 lens로 위임한다
{type !== 'BASIC' && <ExtraFields lens={lens.focus('extra').defined()} />}

Lens는 "어디를 가리키나"만 책임지고, 값 구독은 useWatch 혹은 useController에 넘기는 구조입니다.

데이터 형태가 다를 때 (reflect)

그럼에도 불구하고 앞서 살펴본 문제가 하나 남아 있습니다. 바로 재사용 컴포넌트가 기대하는 스키마 구조와 부모 폼이 다른 경우입니다.

CategoryField{ group, name }을 기대하는데, 부모 폼 필드는 primaryGroup/label이라면? 이를 대응하기 위해 라이브러리에서 reflect라는 함수를 제공합니다.

function ParentForm() {
  const lens = useLens({ control }); // Lens<{ primaryGroup: string; label: string }>
  return (
    <CategoryField
      categoryLens={lens.reflect(({ primaryGroup, label }) => ({
        group: primaryGroup, // primaryGroup은 값이 아니라 Lens<string>
        name: label,
      }))}
    />
  );
}

콜백이 받는 { primaryGroup, label }은 값이 아니라 Lens입니다. 새 키에 reflect로 추출한 Lens를 배치하면 react-hook-form 내부적으로는 여전히 원래 primaryGroup/label 필드를 가리키지만, 컴포넌트 입장에서는 { group, name }으로만 보입니다.

해당 구조를 통해 스키마가 달라도 reflect만으로 같은 컴포넌트를 그대로 쓸 수 있습니다.

타입 단언 (defined)

lens.focus('extra').defined()처럼 .defined()를 붙여 타입을 좁힐 수 있습니다. optional 필드를 focus하면 타입이 Lens<T | undefined>가 되는데, ExtraFields가 기대하는 건 Lens<T>라 그냥 주입하면 타입이 맞지 않습니다.

lens.focus('extra');            // Lens<Extra | undefined>
lens.focus('extra').defined();  // Lens<Extra>

.defined()는 타입만 좁히는 것이지, 런타임 값이 실제로 undefined인지는 별개입니다. 비슷한 기능으로 narrow/assert/cast도 있지만, defined로 대부분 해결할 수 있습니다.

배열로 사용할 때 (useFieldArray)

배열은 Lens 전용 useFieldArraymap을 조합합니다.

import { useFieldArray } from '@hookform/lenses/rhf'; // ⚠️ react-hook-form 아님

function CategoryListForm({ lens }: { lens: Lens<{ group: string; name: string }[]> }) {
  const { fields, append, remove } = useFieldArray(lens.interop());
  return (
    <>
      {lens.map(fields, (field, itemLens) => (
        <CategoryField key={field.id} categoryLens={itemLens} />
      ))}
      <button type="button" onClick={() => append({ group: '', name: '' })}>
        추가
      </button>
    </>
  );
}

react-hook-form 자체 useFieldArraylens.interop()을 그냥 넘겨도 기능상으로는 크게 문제가 없습니다. 다만 데이터 스키마 구조를 reflect로 수정한다면 어긋날 수 있습니다.

이유는 다음과 같습니다. Lens 전용 useFieldArrayreact-hook-form 위에 transformer(getTransformer/setTransformer를 얹은 Wrapper 형태)이고, 데이터 스키마 변환은 이 transformer의 몫입니다. Lens의 interop(){ control, name }에 더해 이 transformer를 담아 돌려주는데, Lens 전용 useFieldArray만 그것을 읽습니다. react-hook-form 자체 훅은 그냥 무시해버립니다.

react-hook-form의 useFieldArray @hookform/lenses의 useFieldArray
{ control, name } 받기
reflect로 shape 변환 ❌ transformer 무시 → 데이터 불일치 ✅ transformer 적용
타입 정합 interop()의 name과 마찰 interop 반환과 정합.

DO_NOT_USE_HOOK_FORM_CONTROL_SHIM

Lens 기반 데이터를 콘솔에 찍어보면 name__DO_NOT_USE_HOOK_FORM_CONTROL_SHIM__ 같은 요상한 값으로 나옵니다. 요런 구조는 타입 체크용 트릭입니다. react-hook-form이 기대하는 Control<T> 형태는 T가 "필드 경로를 가진 객체"라는 가정이 있습니다. 그런데 Lens<string>처럼 원시 값을 가리키는 Lens는 애초에 경로랄 게 없습니다. Control<string> 형태로는 성립하지 않는 이유입니다.

// ❌ 타입상 성립하지 않는다
function Test({ control }: { control: Control<string> }) {}

그래서 라이브러리가 "T가 필드 하나짜리 객체인 척" 시뮬레이션합니다.

function Test({ control }: { control: Control<{ __DO_NOT_USE_NON_OBJECT_FIELD_SHIM__: number }> }) {
  const { field } = useController({ control, name: '__DO_NOT_USE_NON_OBJECT_FIELD_SHIM__' });
  console.log(field.value); // number
}

이 구조 덕분에 Lens<number> 같은 원시 값 Lens도 useController에 바로 적용할 수 있습니다. 타입에서만 보이는 가짜 name 값이고, 런타임에서는 Lens.interop()이 진짜 name 값을 반환하기에 문제가 없습니다.

function Test({ lens }: { lens: Lens<number> }) {
  const { field } = useController(lens.interop());
  console.log(field.value); // number — 런타임 name은 정상
}

react-hook-form 자체 훅에 Lens.interop()을 직접 넘기면 이 가짜 name 타입과 어긋날 수 있다는 것만 기억하면 됩니다.

💡 간단 정리

  • react-hook-form 구조의 폼 컴포넌트를 재사용하고 싶을 땐 useLens를 적극 활용합니다.
  • Lens로 값을 다룰 땐 hook 기반의 useWatchuseController를 사용합니다.
  • Lens에서 제공하는 구조를 이해하고 사용합니다 (focus/interop/defined).
  • 재사용 컴포넌트가 다른 스키마를 만나면 reflect로 변환합니다.
  • 배열은 무조건 @hookform/lenses/rhfuseFieldArray를 씁니다.

🔗 참고

#react-hook-form#useLens#타입추론#Type-Safe#강결합#재사용성#Typescript#zod#useFieldArray#React#폼 컴포넌트 설계#폼