Form을 쓸 때마다 useForm, FormProvider, zodResolver, defaultValues 설정을 반복하고 있나요? 이 글에서는 **React Hook Form(RHF)**과 Zod를 묶어 매 화면에서 동일한 보일러플레이트를 제거하는 FieldsetFormProvider 래퍼를 만들고, useFormContext로 하위 컴포넌트에서 간결하게 사용하는 패턴을 정리합니다.
환경: React 19, react-hook-form v7, zod, @hookform/resolvers
1) 문제 정의
- 매 폼에서 useForm({ mode, resolver, defaultValues })를 반복 입력
- 폼 필드가 깊게 중첩되어 props 드릴링이 발생
- 스키마 유효성(Zod)과 RHF를 연결할 때 옵션이 분산됨
→ 해결: FormProvider를 라우트/페이지 최상단에 배치하고, 옵션을 한 곳에서 캡슐화한 래퍼 컴포넌트를 사용.
2) FieldsetFormProvider 구현
- mode, schema, defaultValues만 받으면 내부에서 useForm을 구성
- schema가 있을 때만 zodResolver 적용 (선택적 유효성)
- children 영역은 RHF 컨텍스트를 그대로 공유
import { ReactNode } from "react";
import {
DefaultValues,
FieldValues,
FormProvider,
useForm,
} from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { ZodTypeAny } from "zod";
export default function FieldsetFormProvider<T extends FieldValues>({
children,
mode,
schema,
defaultValues,
}: {
children: ReactNode;
mode: "onChange" | "onBlur" | "onSubmit" | "onTouched" | "all";
schema?: ZodTypeAny;
defaultValues?: DefaultValues<T>;
}) {
const methods = useForm<T>({
mode: mode || "onSubmit",
...(schema && { resolver: zodResolver(schema) }),
...(defaultValues && { defaultValues }),
});
return <FormProvider {...methods}>{children}</FormProvider>;
}
설계 포인트
- 선택적 스키마: 타입만 맞으면 스키마 없이도 사용 가능
- 기본 모드: onSubmit을 기본으로, 인풋 즉시 검증이 필요할 땐 onChange
- 제네릭 T: 폼 데이터 타입을 상위에서 주입하여 useFormContext<T>()와 타입 일관성 유지
팁: 런타임 스키마와 TS 타입을 일치시키려면 z.infer<typeof schema>를 제네릭 T로 전달하세요.
3) 하위 컴포넌트에서 사용하기 (useFormContext)
- useFormContext로 어디서든 register, handleSubmit, formState 접근
- 라우트/페이지 상단에서 FieldsetFormProvider로 감싸기만 하면 됨
import { FieldValues, useFormContext } from "react-hook-form";
function Contents() {
const { register, handleSubmit } = useFormContext();
const onSubmit = async (data: FieldValues) => {
const response = await fetch("/api/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await response.json();
console.log(result);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input type="text" {...register("title")} />
<button type="submit">Submit</button>
</form>
);
}
폼 페이지 예시
export default function TestPage() {
return (
<FieldsetFormProvider
mode="onSubmit"
defaultValues={{ title: "" }}
>
<Contents />
</FieldsetFormProvider>
);
}
4) Zod 스키마와 타입 안전 결합 (선택)
스키마를 도입하면 런타임 검증 + TS 타입 추론을 동시 확보할 수 있습니다.
import { z } from "zod";
const FormSchema = z.object({
title: z.string().min(1, "제목은 필수입니다")
});
type FormValues = z.infer<typeof FormSchema>;
export default function TestPage() {
return (
<FieldsetFormProvider<FormValues>
mode="onSubmit"
schema={FormSchema}
defaultValues={{ title: "" }}
>
<Contents />
</FieldsetFormProvider>
);
}
function Contents() {
const { register, handleSubmit, formState } = useFormContext<FormValues>();
return (
<form onSubmit={handleSubmit((d) => console.log(d))}>
<input type="text" {...register("title")} />
{formState.errors.title && (
<p role="alert">{formState.errors.title.message}</p>
)}
<button type="submit">Submit</button>
</form>
);
}
5) 실무 체크리스트
- 모드 선택
- 입력 즉시 검증: onChange
- 포커스 아웃 시 검증: onBlur
- 제출 시 검증: onSubmit(기본)
- 기본값과 제네릭 일치
- defaultValues의 키/타입이 T와 맞아야 컨텍스트가 안전하게 동작
- API 에러 핸들링 표준화
- onSubmit 내부에서 try/catch + 사용자 피드백 토스트/에러바 연동
- 컴포넌트 경계
- FieldsetFormProvider는 페이지/섹션 단위로 작게 감싸서 불필요한 리렌더를 줄임
- 폼 분할 전략
- 대형 폼은 탭/스텝으로 나누고, 각 스텝을 별도 FormProvider로 분리하거나 useFieldArray를 활용
6) 흔한 함정과 대처
- useFormContext 타입 누락
제네릭 useFormContext<FormValues>()를 생략하면 에러 객체가 any화됩니다. - defaultValues 미설정
비제어 인풋 초기화 경고가 발생할 수 있습니다. 항상 초기값을 명시하세요. - 동일 name 중복
동일한 name을 여러 곳에서 등록하면 마지막 등록이 유효합니다. 네임스페이스(address.city)로 구분하세요. - 비동기 검증과 모드 충돌
서버 검증과 onChange 모드를 함께 쓰면 요청 폭주가 일어날 수 있습니다. trigger를 조건부로 호출하거나 debounce를 적용하세요.
7) 확장 아이디어
- 필드 컴포넌트화: <FormField name="title" label="제목" as={Input} />처럼 라벨/에러/도움을 묶은 공통 컴포넌트
- 스키마-UI 매핑: Zod 스키마에서 placeholder/label 힌트를 읽어 UI에 반영하는 레이어
- RHF + TanStack Query 연계: 제출 성공 시 관련 쿼리 무효화(queryClient.invalidateQueries)로 목록 최신화
8) 요약
- FieldsetFormProvider로 RHF 설정을 캡슐화하면 보일러플레이트 감소와 일관된 검증 전략을 동시에 달성
- useFormContext를 통해 어디서든 폼 상태 접근, Zod와 결합하면 런타임 + 정적 타입 안전 확보