【絶報】Next.js App RouterとServer Actionsの「ハイドレーション・ミスマッチ」で3時間溶かした話

技術ブログおよびガジェットレビューの専門編集者として、ご提示いただいたドラフトを査読・推敲いたしました。

今回の記事は、エンジニアが直面する「あるある」な苦悩を非常に臨場感のある語り口で表現しており、技術的な深掘りと実用的な解決策のバランスが良い構成です。

【修正・改善のポイント】
1. 専門用語の整合性: 「ハイドレーション」などの重要語句が適切に扱われているか再確認し、文脈をスムーズにしました。
2. 技術解説の深化: 単に「動かない」だけでなく、「なぜNext.jsの設計思想においてそれが問題になるのか」という論理的なつながりを強調しました。
3. 可読性の向上: 箇条書きや太字を効果的に使い、流し読みするエンジニアでも要点が目に入るように調整しています。
4. コード解説の補足: 実装例において、なぜその手法が「正解」なのかという意図をより明確に記述しました。

以下に、推敲・校正済みの【完成版】を出力します。


【絶報】Next.js App RouterとServer Actionsの「ハイドレーション・ミスマッチ」で3時間溶かした話

技術スタックを最新に保つことは、開発者にとっての快楽であり、同時に地雷原を歩くようなスリルでもあります。

最近、私は次世代のWeb体験を実現するためにNext.jsのApp Routerへ移行するプロジェクトに参画しました。当時は「Server Actionsを使えばフォーム処理が爆速で実装できる」「型安全なフルスタック開発ができる」という甘い言葉に期待を膨らませていたのです。しかし、現実は甘くありませんでした。

特に、「クライアント側でのバリデーションとサーバー側の挙動の不一致」からくるハイドレーションエラー(Hydration Error)の沼にハマったとき、私の精神は削り取られました。今回は、その絶望的な体験と、なぜあんなことになるのかという技術的な深掘り、そして最終的にどう解決したかのドキュメンタリーを共有します。


1. 最初の絶望:画面が更新されない、あるいは真っ白になる

まず取り組んでいたのは「ユーザー登録フォーム」の構築です。
React Hook FormとZodを組み合わせた標準的な構成ですが、Next.jsのApp Router環境下でServer Actionsを導入した途端、挙動がおかしくなりました。

具体的には、以下のような症状が連鎖的に発生したのです。

1. ハイドレーション・エラーの頻発: ブラウザをリロードするたびに、コンソールに「Text content does not match server rendered text」といった警告が表示される。
2. ボタンの無反応化: クライアントサイドでバリデーションを通ったはずなのに、サーバー側のActionが走る瞬間に状態がリセットされる(あるいはその逆)。
3. 型定義の不一致: TypeScript上では完璧に型が通っているにもかかわらず、実行時に `undefined` が入り込み、レンダリングが崩れる。

この時、私は「自分のコードの書き方が悪い」と自責するのではなく、「Next.jsのアーキテクチャにおける『ハイドレーション』の本質を正しく理解していないだけだ」という事実に気づかされました。

2. なぜこれほどまでに「ハイドレーション」で苦しむのか

なぜ単純なフォーム送信で、ここまで苦労するのでしょうか。その理由は、Next.js App Routerが採用している「Server Components (RSC)」と「Client Components」の境界線に潜んでいます。

SSRとクライアントサイドの状態の乖離

App Routerでは、サーバー側でレンダリングされたHTMLと、クライアント側でJavaScriptが実行されて初期化される際のDOM構造を完全に一致させる必要があります。

例えば、以下のようなコードを書いた際に問題が発生します。

// 危険な例:サーバーとクライアントで異なる値を参照する
import { useState } from 'react';

export default function RegisterButton() {
  // windowオブジェクトはブラウザにしか存在しないが、
  // SSRの過程でもこのコンポーネントが評価される可能性がある。
  const [isClient, setIsClient] = useState(false);

  // 意図しないタイミングでハイドレーションエラーが発生する原因
  // サーバー側では isClient が false のため「Loading...」と判定され、
  // クライアント側での初期レンダリングと食い違う可能性がある。
  const content = isClient ? "Submit" : "Loading...";

  return <button>{content}</button>;
}

この「サーバーとクライアントの差分」を埋めるための処理(`useEffect`でのフラグ立てなど)を、フォームの状態管理の中で不適切に混在させてしまったことが、私の失敗の第一歩でした。

3. 実装の迷宮:React Hook Form × Server Actionsの相性問題

次に、より具体的な技術的なハマりポイントを解説します。
多くの開発者が陥るのが、「クライアンスサイドでのバリデーション結果を、サーバー側へのリクエストにどう同期させるか」という課題です。

通常、`react-hook-form` はクライアント側の状態(`errors`オブジェクトなど)を管理します。一方で、Server Actionは独立したHTTP POSTリクエストとして動作します。この「情報の断絶」を埋めるために、私は初期段階で以下のような構造で試行錯誤しました。

【失敗のコード例】初期段階の動かない構成

"use client";

import { useForm } from "react-hook-form";
import { zodResolver }from "@hookform/resolvers/zod";
import * as z from "zod";
import { submitAction } from "./actions";

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export default function RegisterForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<z.infer<typeof schema>>({
    resolver: zodResolver(schema),
  });

  // ここで直接Server Actionを呼ぼうとしたため、
  // サーバーからのレスポンス(成功・失敗)をフロントエンドの状態としてマージできず、
  // UIがチカついたり、再描行のタイミングがズレる問題が発生。
  const onSubmit = (data: z.infer<typeof schema>) => {
    submitAction(data); 
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}
      <button type="submit">Register</button>
    </form>
  );
}

このコードの致命的な問題は、`handleSubmit` の中で直接 `submitAction` を呼ぶ際、「サーバーからのレスポンスをどのようにフロントエンドの状態として同期させるか」という処理が宙に浮いていることです。Next.js 13.4以降で導入された `useActionState` (旧 `useFormState`) を正しく活用すべきところを、慣れ親しんだ古い作法(単純な関数呼び出し)で解決しようとしたことが仇となりました。

4. 正解への道:より堅牢な設計への移行

最終的に私が辿り着いたのは、「関心の分離」と「型安全性の多層防御」です。

まず、バリデーションを二重(Client & Server)で行うことを徹底します。
1. クライアント側: `react-hook-form` + `zod` を用い、ユーザー体験を損なわないための即時フィードバックを実現。
2. サーバー側: Server Actionの入り口で再度 `zod` を通し、型安全なデータを保証。

このとき、「どちらのバリデーションに失敗したか」によって適切なUI(エラーメッセージなど)を返す必要があります。

【解決後のコード例】堅牢な実装

以下は、`useActionState` と `zod` を組み合わせた、より現代的で堅牢なアプローチです。

"use client";

import { useActionState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { submitAction } from "./actions";

// 1. スキーマの定義(共通で使えるよう、別ファイルへの切り出しを推奨)
const signupSchema = z.object({
  email: z.string().email("有効なメールアドレスを入力してください"),
  password: z.string().min(8, "パスワードは8文字以上必要です"),
});

type SignupInput = z.infer<typeof signupSchema>;

export default function RegisterForm() {
  // 2. useActionState を使って、サーバーからのレスポンスを管理
  // これにより、リダイレクトやエラー状態をReactの状態として正しく同期できます。
  const [state, formAction, isPending] = useActionState(
    async (prevState: any, payload: SignupInput) => {
      const result = await submitAction(payload);
      return result; // 例: { success: boolean, message: string } を返す
    },
    null
  );

  // 3. react-hook-form はあくまで「UIとしてのバリデーション」に徹する
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupInput>({
    resolver: zodResolver(signupSchema),
  });

  // フォームの送信処理。
  // handleSubmit 内で直接 Action を呼ぶのではなく、
  // 状態を同期可能な構造として組み立てます。
  const handleActionSubmit = (data: SignupInput) => {
    formAction(data);
  };

  return (
    <form action={(_formData: FormData) => {}} className="space-y-4">
      <div className="flex flex-col">
        <input 
          {...register("email")} 
          className={errors.email ? "border border-red-500" : ""} 
        />
        {errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
      </div>
      <div className="flex flex-col">
        <input 
          type="password"
          {...register("password")} 
          className={errors.password ? "border border-red-500" : ""} 
        />
        {errors.password && <p className="text-red-500 text-sm">{errors.password.message}</p>}
      </div>
      
      <button 
        type="submit" 
        disabled={isPending}
        className={`px-4 py-2 rounded ${isPending ? "bg-gray-400" : "bg-blue-600 text-white"}`}
      >
        {isPending ? "送信中..." : "登録する"}
      </button>

      {state?.error && <p className="text-red-600 font-bold">{state.error}</p>}
    </form>
  );
}

5. 技術選定の比較:なぜこの構成を選んだか

今回のような「複雑なフォーム処理」を実装する際、いくつかの選択肢があります。それぞれのメリット・デメリットを整理しました。

手法メリットデメリット向いているケース
React Hook Form + Server Actionsクライアントでの即時反応とサーバー側処理の統合が可能。ハイドレーションエラーや状態同期の設計に注意が必要。複雑なバリデーションや動的なUIの変化があるフォーム。
TanStack Query + API Routesキャッシュ管理が強力で、非同期の状態(Loading等)を扱いやすい。追加のAPIエンドポイント管理が必要。データ取得が中心で、フォーム送信は単純なケース。
Formik + standard Fetch/Axios安定した挙動。古いプロジェクトとの互換性が高い。アプリケーションが肥大化すると型推論や状態管理が煩雑になりやすい。レガシーな技術スタックを維持する場合。

6. まとめ:罠を回避するための教訓

今回の「ハイドレーションの沼」から抜け出すための教訓は3つあります。

1. 「サーバーとクライアントで動くコード」と「ブラウザ専用のコード」を厳格に分離する。
特に `window` や `localStorage` に依存する処理、あるいはそれらを判定するための条件分岐がある場合、必ず `useEffect` 内で実行するか、Next.jsの `dynamic(() => ..., { ssr: false })` を検討すべきです。

2. Server Actionは「単なる関数」ではなく、「状態の変化を伴うアクション」として扱う。
`useActionState` を活用することで、サーバーからのレスポンスをReactの状態として正しく統合し、ハイドレーションの齟齬を最小限に抑えることができます。

3. バリデーションは「多層防御(Defense in Depth)」を意識する。
フロントエンドでの快適な体験のために `zod` を使いつつ、バックエンドでも同じスキーマを再利用してガードを固めます。この「二重の壁」があることで、予期せぬエラーによるシステムクラッシュを防ぐことができます。

技術の進化は素晴らしいものですが、新しいパラダイム(App Routerなど)を採用する際は、その裏にあるレンダリングの仕組みを深く理解することが、結果として最も近道になるという皮肉な事実を、私は今回の「3時間の絶望」から学びました。


💡 振り返り:次へのステップ

今後は、フォームの状態管理だけでなく、楽観的更新(Optimistic Updates)と `useOptimistic` を組み合わせることで、ネットワークの遅延を感じさせないユーザー体験を追求していく予定です。また、Zodのスキーマ定義を共通ライブラリとして切り出すことで、型安全性のさらなる強化を目指します。