React Hook Formで3層アーキテクチャを実装する

React Hook Formを使っていると、気づいたらフォームコンポーネントが400行を超えている…なんてことありませんか?バリデーション、API呼び出し、UIの状態管理が全部1つのファイルに詰め込まれて、どこから手をつけていいかわからなくなる。

海外のReactコミュニティでは、この問題を解決するために3レイヤーアーキテクチャという設計パターンが広まっています。今回は、この設計パターンの具体的な実装方法を紹介します。

よくある問題:巨大なフォームコンポーネント

典型的なReact Hook Formのコンポーネントを見てみましょう。

// UserForm.tsx - 全部入りパターン(よくない例)
export const UserForm = () => {
  const { data } = useQuery(['user'], fetchUser);
  const mutation = useMutation(updateUser);

  const { register, handleSubmit, formState: { errors } } = useForm({
    defaultValues: {
      name: data?.name ?? '',
      email: data?.email ?? '',
      // ...20個くらいのフィールド
    },
    resolver: zodResolver(userSchema),
  });

  const onSubmit = async (formData) => {
    try {
      await mutation.mutateAsync(formData);
      toast.success('保存しました');
      router.push('/users');
    } catch (e) {
      toast.error('エラーが発生しました');
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* 200行くらいのJSX */}
    </form>
  );
};

このパターンの問題点は、「責務が混在している」ことです。

  • データ取得(useQuery)
  • フォーム状態管理(useForm)
  • 送信後の処理(toast、router)
  • UI描画(JSX)

全部が1ファイルに入っていると、テストも難しいし、部分的な再利用もできません。

海外で広まる3レイヤーアーキテクチャ

海外の記事では、この問題を3つのレイヤーに分けて解決するアプローチが紹介されています。SOLIDの「単一責任の原則」をReactコンポーネントに適用した形ですね。

┌─────────────────────────────────────┐
│  Apollo Layer (Network)             │  ← API通信のみ
├─────────────────────────────────────┤
│  Logic Layer (Form State)           │  ← フォーム状態管理
├─────────────────────────────────────┤
│  View Layer (Presentation)          │  ← UI描画のみ
└─────────────────────────────────────┘

各レイヤーは以下の責務を持ちます。

レイヤー責務依存するライブラリ
Apolloデータ取得・送信TanStack Query, axios
Logicフォーム状態・バリデーションReact Hook Form, Zod
ViewUI描画UIライブラリのみ

海外では、ファイル名に*Apollo.tsx*Logic.tsx*View.tsxというサフィックスをつける命名規則も提唱されています。

各レイヤーの実装例

View Layer:UIだけに集中

まず、Viewレイヤーです。ここにはReact Hook Formへの依存を持ち込みません。

// UserFormView.tsx
type UserFormViewProps = {
  name: string;
  email: string;
  errors: {
    name?: string;
    email?: string;
  };
  onNameChange: (value: string) => void;
  onEmailChange: (value: string) => void;
  onSubmit: () => void;
  isSubmitting: boolean;
};

export const UserFormView = ({
  name,
  email,
  errors,
  onNameChange,
  onEmailChange,
  onSubmit,
  isSubmitting,
}: UserFormViewProps) => {
  return (
    <form onSubmit={onSubmit}>
      <TextField
        label="名前"
        value={name}
        onChange={(e) => onNameChange(e.target.value)}
        error={errors.name}
      />
      <TextField
        label="メールアドレス"
        value={email}
        onChange={(e) => onEmailChange(e.target.value)}
        error={errors.email}
      />
      <Button type="submit" disabled={isSubmitting}>
        保存
      </Button>
    </form>
  );
};

Viewレイヤーは「受け取ったpropsを描画するだけ」です。フォームライブラリを別のものに変えても、このコンポーネントには影響しません。

Logic Layer:フォーム状態を管理

Logicレイヤーでは、React Hook Formを使ってフォームの状態とバリデーションを管理します。

// UserFormLogic.tsx
type UserFormLogicProps = {
  defaultValues: UserFormValues;
  onSubmitSuccess: (data: UserFormValues) => void;
};

export const UserFormLogic = ({
  defaultValues,
  onSubmitSuccess,
}: UserFormLogicProps) => {
  const {
    watch,
    setValue,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<UserFormValues>({
    defaultValues,
    resolver: zodResolver(userFormSchema),
  });

  const name = watch('name');
  const email = watch('email');

  const onSubmit = handleSubmit((data) => {
    onSubmitSuccess(data);
  });

  return (
    <UserFormView
      name={name}
      email={email}
      errors={{
        name: errors.name?.message,
        email: errors.email?.message,
      }}
      onNameChange={(v) => setValue('name', v)}
      onEmailChange={(v) => setValue('email', v)}
      onSubmit={onSubmit}
      isSubmitting={isSubmitting}
    />
  );
};

ポイントは、onSubmitSuccessというコールバックを受け取っていることです。送信後に何をするかは、このレイヤーでは決めません。

Apollo Layer:API通信を担当

最後に、Apolloレイヤー(Network層)です。TanStack QueryやAPIクライアントへの依存はここに閉じ込めます。

// UserFormApollo.tsx
export const UserFormApollo = ({ userId }: { userId: string }) => {
  const { data, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  const mutation = useMutation({
    mutationFn: updateUser,
  });

  const router = useRouter();

  if (isLoading) return <Skeleton />;

  // データを変換してフォーム用のdefaultValuesを作る
  const defaultValues: UserFormValues = {
    name: data?.name ?? '',
    email: data?.email ?? '',
  };

  const handleSubmitSuccess = async (formData: UserFormValues) => {
    try {
      await mutation.mutateAsync({ userId, ...formData });
      toast.success('保存しました');
      router.push('/users');
    } catch (e) {
      toast.error('エラーが発生しました');
    }
  };

  return (
    <UserFormLogic
      defaultValues={defaultValues}
      onSubmitSuccess={handleSubmitSuccess}
    />
  );
};

このレイヤーでは、APIからのデータをフォーム用に変換し、送信成功後の処理(トースト表示、ページ遷移)を担当します。

Dual Submit Handlers Pattern

海外の記事で紹介されていた面白いパターンがあります。Dual Submit Handlersと呼ばれる手法です。

通常、handleSubmitは1つですが、このパターンではApollo層とLogic層でそれぞれhandleSubmitを持ちます。

// Logic層のhandleSubmit
const onSubmit = handleSubmit((data) => {
  // フォームデータの変換・正規化
  const normalizedData = normalizeFormData(data);
  onSubmitSuccess(normalizedData);
});

// Apollo層のhandleSubmitSuccess
const handleSubmitSuccess = async (formData) => {
  // API送信とその後の処理
  await mutation.mutateAsync(formData);
  toast.success('保存しました');
};

なぜ分けるかというと、責務が違うからです。

  • Logic層: フォームデータの変換(日付のフォーマットとか)
  • Apollo層: API送信とUI状態の更新(トースト、ルーティング)

これにより、Logic層は「フォームロジックのテスト」、Apollo層は「API連携のテスト」と、テストも分けやすくなります。

2025年のトレンド:Composable Form Handling

ちなみに、海外では2025年のフォーム設計としてComposable Form Handlingという概念も出てきています。

具体的には:

  • 状態管理の分離: ZustandやJotaiでフォーム状態を管理し、UIライブラリから独立させる
  • RSC統合: 静的なフィールドはサーバーでレンダリング、インタラクティブな部分だけクライアントでhydrate
  • アクセシビリティ優先: Radix UIのようなunstyled primitiveを使って、ARIA属性を確実に

React Hook Formは週間ダウンロード数が約700万と圧倒的なシェアを持っていますが、バンドルサイズはわずか10KB程度。Formik(約32KB)と比べてもかなり軽量です。

まとめ

React Hook Formで巨大になりがちなフォームコンポーネントを、3レイヤーアーキテクチャで整理する方法を紹介しました。

  • View: UI描画のみ(フォームライブラリに依存しない)
  • Logic: フォーム状態・バリデーション
  • Apollo: API通信・送信後の処理

海外では*Apollo.tsx*Logic.tsx*View.tsxという命名規則も提唱されています。最初は面倒に感じるかもしれませんが、チーム開発では「デザイナーはViewだけ触る」「バックエンドエンジニアはApolloだけ触る」といった分担ができるメリットもあります。

フォームの規模が大きくなってきたら、試してみてください。