🍋

Server Actionsではconformを使うのが良さそう@2024/09/05

2024/09/05に公開

はじめに

Next.jsでServer Actionsを使ったフォーム処理を記述していたら実装に手間取ったのと、conformというライブラリが良さそうだったのでメモを残しておきます。

結論

Server Actionsにはconformを使うが良さそうです。なぜ「@2024/09/05」かというと、react-hook-formも今後Server Actionsに対応予定だからです。
私も含めreact-hook-formのほうが馴染みがある人は多いのではないでしょうか。

https://github.com/edmundhung/conform

フォーム要件

  • フロントでzodによるバリデーションを行う
  • サーバーでもzodによるバリデーションを行う
  • エラーメッセージをテキストボックスの下に表示する

画面イメージ

conformを使用しない自力実装

page.tsx
"use client";

import { execute } from "@/app/playground1/action";
import { adjustError, safeParse } from "@/app/playground1/validation";
import type { FieldErrors } from "@/lib/validation";
import type { FormEvent } from "react";
import { useState } from "react";
import { useFormState } from "react-dom";

export default function PlaygroundPage1() {
  const [state, action] = useFormState(execute, {
    name: "husky",
  });

  const [clientErrors, setClientErrors] = useState<FieldErrors | undefined>();
  const fieldErrors = clientErrors ?? state.errors;

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    const formData = new FormData(event.currentTarget);
    const { success, error } = safeParse(formData);

    if (success) {
      setClientErrors(undefined);
    } else {
      const errors = adjustError(error);
      setClientErrors(errors);
      event.preventDefault();
    }
  };

  return (
    <form onSubmit={handleSubmit} action={action} noValidate>
      <input type="text" name="name" defaultValue={state.name} />
      <p style={{ color: "red" }}>{fieldErrors?.name.message}</p>
      <button type="submit">Submit</button>
    </form>
  );
}
action.ts
"use server";

import type { FieldErrors, FormState } from "@/app/playground1/validation";
import { adjustError, safeParse } from "@/app/playground1/validation";
import { redirect } from "next/navigation";

export const execute = async (
  prevState: FormState,
  formData: FormData,
): Promise<FormState & { errors?: FieldErrors }> => {
  const { success, error } = safeParse(formData);
  if (!success) {
    return {
      ...prevState,
      errors: adjustError(error),
    };
  }

  // なにか変更処理

  redirect("/");
};
validation.ts
import { z } from "zod";

export const schema = z.object({
  name: z.string().max(4),
});

export type FormState = z.infer<typeof schema>;
export type FieldErrors = Record<string, { message: string }>;

export const safeParse = (formData: FormData) =>
  schema.safeParse(Object.fromEntries(formData));

export const adjustError = (
  error: z.ZodError<{
    name: string;
  }>,
): FieldErrors =>
  Object.fromEntries(
    error.errors.map(({ path, message }) => [path[0], { message }]),
  );

見て取れるように結構大変でした。(若輩者ゆえ、きっともっと良い書き方はあるだろう)
ざっくり以下の処理が必要になってきます。

  1. フロントとサーバーで共通のzodバリデーション処理を用意する
  2. フロントとサーバーで共通のzodエラーオブジェクト変換処理を用意する
  3. 1と2をフロントとサーバーで間違えがないように実装する
  4. フロントで、フロントとサーバーのエラーをマージする
  5. 検証通過時にエラーをリセットする処理を入れる

action={action}を消してもonSubmit={handleSubmit}を消してもバリデーションが動いていることから、フロントとサーバー両方でバリデーションが行われていることが確認できます。

ですが、onBlur時やonInput時に検証が動いていません。
これは実装していないだけですが、さらに記述量が増えてしまうので割愛しています。

conformを使用して実装

page.tsx
"use client";

import { useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { redirect } from "next/navigation";
import { useFormState } from "react-dom";
import { z } from "zod";

const schema = z.object({
  name: z.string().max(4),
});

const execute = async (_: unknown, formData: FormData) => {
  "server action";

  const submission = parseWithZod(formData, {
    schema,
  });

  if (submission.status !== "success") {
    return submission.reply();
  }

  // なにか変更処理

  redirect("/");
};

export default function PlaygroundPage2() {
  const [lastResult, action] = useFormState(execute, {
    initialValue: {
      name: "samoyed",
    } satisfies z.infer<typeof schema>, // もっと良い方法あるかな??
  });

  const [form, fields] = useForm({
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema });
    },
    shouldValidate: "onBlur",
    shouldRevalidate: "onInput",
  });

  return (
    <form id={form.id} onSubmit={form.onSubmit} action={action} noValidate>
      <input type="text" name="name" defaultValue={fields.name.initialValue} />
      <p style={{ color: "red" }}>{fields.name.errors}</p>
      <button type="submit">Submit</button>
    </form>
  );
}

記述量がそこまで多くないので1枚のファイルに記述してみました。(良いか悪いかは置いておき)

  1. フロントとサーバーで共通のzodバリデーション処理を用意する
  2. フロントとサーバーで共通のzodエラーオブジェクト変換処理を用意する
  3. 1と2をフロントとサーバーで間違えがないように実装する
  4. フロントで、フロントとサーバーのエラーをマージする
  5. 検証通過時にエラーをリセットする処理を入れる

ここあたりの処理が良い感じに隠蔽されスッキリしました。

    onValidate({ formData }) {
      return parseWithZod(formData, { schema });
    },

react-hook-formの@hookform/resolversの用に@conform-to/zodのparseWithZodを使用することで、conformとzodの連携が簡単に行えます。

    shouldValidate: "onBlur",
    shouldRevalidate: "onInput",

自力で実装したときは記述量の関係で割愛したonBlur,onInputの処理も上記のように記述するだけです。(そう、react-hook-formの用に)

フロントとサーバー両方のバリデーションに加え、onBlur,onInputも動いているのが確認できます。

まとめ

react-hook-formの特徴である「入力の度に再描画を行わない」もクリアされているようなので、とりあえずはconform使っていくで良いかなと思っています。

おまけ

useActionStateはまだ使えませんでした。

執筆時点のNext.jsの最新バージョンは「14.2.7」です。
useActionStateはバージョン「14.3」以降で使えるようになるそうです。
まじかあー…(結構時間使った)
でも実装方法はuseFormStateでもuseActionStateでもあまり変わらなそうなので、リリース待たずして記事を書きました。

https://github.com/vercel/next.js/issues/65673#

const { pending } = useFormStatus();常にfalse問題

useFormStatus has to be used inside a child component of the form and it kinda watches the state change of the server action.

https://stackoverflow.com/questions/77789571/useformstatus-is-always-false-in-nextjs

<form>の定義と同階層だと動かないんですね。
まじかー…(結構時間使った)

以下の用にconst { pending } = useFormStatus();を内部に持つボタンを作成し、これを<form>内で使用したら想定通りpendingが機能しました。(これ正しい実装??)

FormButton.tsx
"use client";

import { Button } from "@/components/ui/button";
import type { ComponentProps } from "react";
import { useFormStatus } from "react-dom";

export type FormButtonProps = ComponentProps<typeof Button>;

export const FormButton: React.FC<FormButtonProps> = (props) => {
  const { pending } = useFormStatus();
  return <Button isLoading={pending} {...props} />;
};

参考にさせていただきました。

Discussion