🐢

Server ActionsとReact Hook Formを統合するFormコンポーネントを使ってみた

に公開

今更ながらNext.jsを勉強中なのですが、Server ActionsとReact Hook Formを組み合わせた情報があまり見つからなかったので記事に残しておきます。

Server Actionsとは

Reactの機能として導入された“サーバーで動く関数” で、Nextでは14から安定版として使えるようになった機能です(Reactのドキュメントではより広義な意味を持つServer Functionsという言葉を定義しており、特にフォームのactionなどで使う場合にServer Actionsと呼んでいるようです)。
"use server"をつけて書くことでその関数はサーバ側で実行され、この仕組みを使うことでフォームの処理を簡単に実装できたりします。サーバ側で実行されるため、別でAPIなどを用意しなくても以下の例のように直接DBに保存する処理を書けます。
※実際のプロダクトでは認証処理が必要です

↓ サーバー側(Server Action)

app/actions.ts
"use server";

export async function createPost(formData: FormData) {
  // DB 保存など
}

↓ フロント側

import { createPost } "@/app/actions"

(中略)

<form action={createPost}>
  <input name="title" />
  <button>送信</button>
</form>

詳細な説明は公式ドキュメントがわかりやすかったです。

https://ja.react.dev/reference/rsc/server-functions

なぜReact Hook Formを使うのか?

Zodなどのスキーマライブラリを使ったバリデーションを上記のServer Action内で実行することで、最低限のフォーム機能は作れると思いますが、React Hook Formを使うことで以下の恩恵を受けられます。

  • 各入力値の状態管理を簡潔に書ける
  • Zodなどのスキーマライブラリを使ったクライアントでのバリデーション及びそのハンドリングを簡単に実装できる

こちらも基本的な使い方は公式ドキュメントがわかりやすいです。

https://react-hook-form.com/get-started

コード例

本題のコード例です。

まずはサーバ側のコードです。ポイントは以下です。

  • フロント側で記述するuseActionStateを使用するため、prevStateを第一引数に持ち、stateをreturnする必要がある
  • zodのsafeParseを使ってバリデーションを実行し、引っかかった場合は返却するstateに反映させる

↓サーバー側(Server Action)

app/actions.ts
"use server";
import { z } from "zod";

const schema = z.object({
  title: z.string().min(1, "タイトルは必須です"),
});

export type State = {
  errors: {
    title?: string[];
  };
  message: string;
};

export async function createPost(prevState: State, formData: FormData) {
  const validateFields = schema.safeParse(Object.fromEntries(formData));

  if (!validateFields.success) {
    return {
        errors: validateFields.error.flatten().fieldErrors,
        message: "failed in server actions validation"
    };
  }

  // DB 保存などの処理
  return { message: "success", errors: {} };
}

続いてフロント側のコードです。React Hook FormにServer Actionsと統合するためのFormコンポーネントがあるのでこれを使います。

https://react-hook-form.com/docs/useform/form

ポイントは以下です。

  • FormのonSubmitで実行するのは、useActionStateで作成したアクション
  • useFormの戻り値であるcontrolをFormコンポーネントに渡すことで、submit前のバリデーションの実行などしてくれる
  • @hookform/resolvers/zodを使うことで、Zodで定義したスキーマをReact Hook Formで使えるようにする

↓フロント側

"use client";

import { useActionState, startTransition } from "react";
import { Form, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createPost, schema, type State } from "@/app/actions";

export default function Page() {
  const initialState: State = { message: "", errors: {} }
  const [state, formAction, isPending] = useActionState(createPost, initialState);

  const {
    register,
    control,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(schema),
  });

  return (
    <Form
      control={control}
      onSubmit={({ formData }) => {
        startTransition(() => formAction(formData));
      }}
    >
      <input {...register("title")} placeholder="タイトル" />
      {errors.title && <p>{errors.title.message}</p>} // フロントのエラー
      {state.errors?.title?.map((error: string) => ( // サーバのエラー
        <p>{error}</p>
      ))}

      <button type="submit" disabled={isPending}>
        {isPending ? "送信中..." : "送信"}
      </button>
    </Form>
  );
}

つまずいたポイント

<Form action={formAction}> と記載するとhydrationエラー

リンクを貼ったReactの公式ドキュメントと同じように普通の<form>と同じノリでaction={formAction}を渡せるだろうと思っていたのですが、hydrationエラーが出ました。

ネイティブの<form>の場合は、Nextが渡された関数がServer Actionsであることを認識して、SSR時とクライアント側のhydration(HTMLにJavaScriptをアタッチしてインタラクティブにする)の両方でうまく扱ってくれます。

一方でReact Hook Formの<Form>の場合は、単なるReactコンポーネントのpropsとして扱われるので、

  • サーバでのレンダリング時ではactionに渡された関数をうまくシリアライズできない(action = ""という形になる)
  • クライアント側ではaction={関数}が存在する前提でhydrationしようとする

状態になり、サーバとクライアントが生成するHTMが不一致になりエラーとなる様です。
Nextは純粋な<form>の時だけはこういった不整合が起きない特殊処理を行ってくれてるようです。

一方で、onSubmitのpropsで関数を渡せば、

  • サーバーHTMLには onSubmit="" のような属性が存在しない
  • クライアント側でイベントリスナーとしてReactが後から関数を紐付ける

ので、サーバとクライアントのHTMLの不一致が起きずエラーとならないようです。

このあたりを理解する上で、SSRやRSCでのレンダリングの仕組みを解説されている、以下の記事がとてもわかりやすかったです。

https://zenn.dev/yuu104/articles/react-server-component

useActionStateのformActionはstartTransitionで囲む必要がある

startTransitionで囲まなかった場合、以下のエラーが出ました。

An async function with useActionState was called outside of a transition.

自分はそもそもトランジションってなんだっけ?という状態だったので、以下を参考にさせていただきました。

https://zenn.dev/aishift/articles/90b5ce2436e059

要するに、「UIにすぐ反映しなくてもいい “低優先度の状態更新” を、通常の更新と分けて扱うための仕組み」です。

今回の例で言うと、useActionStateを使うと、Reactは「この処理は画面に影響が出る大きめの非同期処理だよね?」と判断します。
ただ、その非同期処理を“普通の関数呼び出し”のまま実行してしまうと、Reactの認識とのズレが生じて、上記のエラーが出ます。

ちなみに、素の <form action={serverAction}> を使う場合は、Next側が内部でよしなに扱ってくれるので自分で startTransition を書く必要はありません。

まとめ

React Hook Formの<Form>コンポーネントとServer Actionsを組み合わせると、「型安全なバリデーション→Server Actionの実行」までをとてもシンプルに書けるようになります。

どなたかの参考になれば嬉しいです。

Discussion