본문 바로가기
FrontEnd

React Hook Form + Zod로 FormProvider 만들기

by E_van 2025. 10. 21.

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와 결합하면 런타임 + 정적 타입 안전 확보