Server Actionsではconformを使うのが良さそう@2024/09/05
はじめに
Next.jsでServer Actionsを使ったフォーム処理を記述していたら実装に手間取ったのと、conformというライブラリが良さそうだったのでメモを残しておきます。
結論
Server Actionsにはconformを使うが良さそうです。なぜ「@2024/09/05」かというと、react-hook-formも今後Server Actionsに対応予定だからです。
私も含めreact-hook-formのほうが馴染みがある人は多いのではないでしょうか。
フォーム要件
- フロントでzodによるバリデーションを行う
- サーバーでもzodによるバリデーションを行う
- エラーメッセージをテキストボックスの下に表示する
画面イメージ
conformを使用しない自力実装
"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>
);
}
"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("/");
};
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 }]),
);
見て取れるように結構大変でした。(若輩者ゆえ、きっともっと良い書き方はあるだろう)
ざっくり以下の処理が必要になってきます。
- フロントとサーバーで共通のzodバリデーション処理を用意する
- フロントとサーバーで共通のzodエラーオブジェクト変換処理を用意する
- 1と2をフロントとサーバーで間違えがないように実装する
- フロントで、フロントとサーバーのエラーをマージする
- 検証通過時にエラーをリセットする処理を入れる
action={action}
を消してもonSubmit={handleSubmit}
を消してもバリデーションが動いていることから、フロントとサーバー両方でバリデーションが行われていることが確認できます。
ですが、onBlur時やonInput時に検証が動いていません。
これは実装していないだけですが、さらに記述量が増えてしまうので割愛しています。
conformを使用して実装
"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枚のファイルに記述してみました。(良いか悪いかは置いておき)
- フロントとサーバーで共通のzodバリデーション処理を用意する
- フロントとサーバーで共通のzodエラーオブジェクト変換処理を用意する
- 1と2をフロントとサーバーで間違えがないように実装する
- フロントで、フロントとサーバーのエラーをマージする
- 検証通過時にエラーをリセットする処理を入れる
ここあたりの処理が良い感じに隠蔽されスッキリしました。
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
でもあまり変わらなそうなので、リリース待たずして記事を書きました。
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.
<form>の定義と同階層だと動かないんですね。
まじかー…(結構時間使った)
以下の用にconst { pending } = useFormStatus();
を内部に持つボタンを作成し、これを<form>内で使用したら想定通りpendingが機能しました。(これ正しい実装??)
"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} />;
};
参考にさせていただきました。
-
Server Actions時代のformライブラリconform
- conformに関してはこちらが一番わかり易かったです。
-
Next.jsの考え方
- これが無料ってすごいですね。この中に出てくるContainer/Presentationalの考え方好きです。
-
実践Next.js —— App Routerで進化するWebアプリ開発
- いつも良い本ありがとうございます!
Discussion