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 |
| View | UI描画 | 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だけ触る」といった分担ができるメリットもあります。
フォームの規模が大きくなってきたら、試してみてください。