Server Actions時代のformライブラリconform
conformはプログレッシブ・エンハンスメントを意識して作られたReactのformライブラリです。
RemixやNext.jsなどのフレームワークをサポートしています。react-hook-formのServer Actions対応は現状検証段階なのですが、conformはすでにServer Actionsにも対応しています。
本稿ではNext.js(App Router)におけるconformの使い方を中心に紹介します。
Server Actions
もう散々他の記事や公式ドキュメントで紹介されていますが、簡単にServer Actionsについて復習します。
基本的な使い方
Server Actionsはクライアントサイドから呼び出すことができる、サーバー側で実行される関数です。
この機能の最も一般的なユースケースは、サーバー側のデータを変更する際に呼び出すことです。ReactはJSXにおけるformタグを拡張しており、<form action={serverAction}>
のようにformのaction
propsにServer Actions関数を指定するなどして利用する方法がドキュメントではよく出てきます。このようにaction
に直接指定することで、JS未ロード時も動作することが可能になります。
Server Actionsは"use server";
ディレクティブによって識別されます。"use server";
は関数スコープの先頭やファイルの先頭に記述します。
// action.ts
"use server";
async function createUser(formData: FormData) {
const fullname = formData.get("fullname");
// データベース操作、cacheのrevalidateなどの処理
}
// page.tsx
export default function Page() {
return (
<form action={createUser}>
<input type="text" name="fullname" />
...
</form>
);
}
useFormState
Server Actionsの実行結果に応じて状態を変更したくなることもあるでしょう。そのような場合にはuseFormState
を利用することができます。
useFormState(action, initialState, permalink?)
のように、action
とinitialState
を引数に取ります。action
はServer Actionsで、initialState
は扱いたい状態の初期値です。以下はNext.jsのドキュメントにある例です。
"use client";
import { useFormState } from "react-dom";
import { createUser } from "@/app/actions";
const initialState = {
message: "",
};
export function Signup() {
const [state, formAction] = useFormState(createUser, initialState);
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input type="text" id="email" name="email" required />
{/* ... */}
<p aria-live="polite" className="sr-only">
{state?.message}
</p>
<button>Sign up</button>
</form>
);
}
他にもServer Actionsの実行状態などを取得することができるuseFormStatus
もありますが、本稿では扱わないため割愛します。
react-hook-form
App Router以前のNext.jsでformライブラリと言うと、筆者はreact-hook-formを利用することが多かったです。react-hook-formを利用することで、zod定義のバリデーションを容易に行うことができました。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
const schema = z.object({
name: z.string(),
age: z.number(),
});
type Schema = z.infer<typeof schema>;
const App = () => {
const { register, handleSubmit } = useForm<Schema>({
resolver: zodResolver(schema),
});
const onSubmit = (data: Schema) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
<input {...register("age", { valueAsNumber: true })} type="number" />
<input type="submit" />
</form>
);
};
しかし前述の通り、本稿執筆時点ではreact-hook-formはServer Actionsに対応していません。かと言ってreact-hook-formが使い慣れてしまった筆者にとって、zod.parse
を自分で呼び出して結果をエラーに変換したりエラー時のハンドリングを自前で設計・実装するのは少々面倒に思えてしまい、Server Actions対応なformライブラリの台頭を待ち望んでいました。そこで最近知ったのがconformです。
conform
以降はconformの特徴や使い方について説明します。
conformの特徴
公式ドキュメントではconformの特徴として、以下が挙げられています。
- Progressive enhancement first APIs
- Type-safe field inference
- Fine-grained subscription
- Built-in accessibility helpers
- Automatic type coercion with Zod
これらを読む限りreact-hook-formからの移行先としては、とても良さそうに思えます。
インストール
zodを使う場合、以下2つをインストールします。
$ pnpm add @conform-to/react @conform-to/zod
Server Actions
公式ドキュメントにNext.jsとconformの実装例があります。ドキュメントだと説明がコメントアウトくらいしかないので、ここではもうちょっと詳細に使い方を説明します。
以下のようなzodスキーマを持つformを実装する場合を考えていきます。
// schema.ts
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
Server Actionは@conform-to/zod
のparseWithZod
を利用することで以下のように書くことができます。
// action.ts
"use server";
import { redirect } from "next/navigation";
import { parseWithZod } from "@conform-to/zod";
import { loginSchema } from "@/app/schema";
export async function login(prevState: unknown, formData: FormData) {
const submission = parseWithZod(formData, {
schema: loginSchema,
});
if (submission.status !== "success") {
return submission.reply();
}
redirect("/dashboard");
}
注目すべきはsubmission.status !== "success"
でバリデーション結果を判定し、エラー時はreturn submission.reply();
としていることです。エラーの詳細の確認やzodErrorのハンドリングなどなしに、ただsubmission.reply()
を返すだけで良いのがとても嬉しいところです。
参考実装なのでバリデーションが通ったらredirect("/dashboard");
としていますが、この前にデータベース操作などの処理を挟むことも当然できます。
formコンポーネント側ではuseFormState
とconformのuseForm
を利用してform
/fields
を取得します。これらはformやinput要素のpropsに渡す情報を持ったオブジェクトです。
// form.tsx
"use client";
import { useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { useFormState } from "react-dom";
import { login } from "@/app/actions";
import { loginSchema } from "@/app/schema";
export function LoginForm() {
const [lastResult, action] = useFormState(login, undefined);
const [form, fields] = useForm({
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: loginSchema });
},
shouldValidate: "onBlur",
});
// action,form,filedsを参照しつつformを組み立てる
}
useFormState
の部分は前述の通りですが、これによって得られるstateをuseForm
のlastResult
に渡しています。lastResult
自体は省略可能なので、form側で状態を扱う必要がなければuseFormState
含め省略してください。
onValidate
でServer Actions側でも利用したparseWithZod
を利用し上記のように1行書けば、サーバーサイドと同じバリデーションをクライアントサイドで実行することが可能になります。
これらによって得られたaction
/form
/fileds
を使って、form要素を組み立てればクライアントサイドバリデーション+Server Actionsなformが完成です。
export function LoginForm() {
const [lastResult, action] = useFormState(login, undefined);
const [form, fields] = useForm({
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: loginSchema });
},
shouldValidate: "onBlur",
});
return (
<form id={form.id} onSubmit={form.onSubmit} action={action} noValidate>
<div>
<label htmlFor={fields.email.id}>>Email</label>
<input type="email" name={fields.email.name} />
<div>{fields.email.errors}</div>
</div>
<div>
<label htmlFor={fields.password.id}>Password</label>
<input type="password" name={fields.password.name} />
<div>{fields.password.errors}</div>
</div>
<label>
<div>
<span>Remember me</span>
<input type="checkbox" name={fields.remember.name} />
</div>
</label>
<Button>Login</Button>
</form>
);
}
カスタムエラー
zodでのバリデーションエラー以外にも、DB操作時にデータ不整合や権限エラーなどを扱うこともあるでしょう。そのような場合にはsubmission.reply({ formErrors: [error.message] })
のような形で、任意のエラーメッセージを渡すことができます。
// action.ts
"use server";
import { redirect } from "next/navigation";
import { parseWithZod } from "@conform-to/zod";
import { loginSchema } from "@/app/schema";
export async function login(prevState: unknown, formData: FormData) {
const submission = parseWithZod(formData, {
schema: loginSchema,
});
if (submission.status !== "success") {
return submission.reply();
}
// redirect("/dashboard");
return submission.reply({
formErrors: ["エラーメッセージ1", "エラーメッセージ2"],
});
}
formErrors
に指定されたエラー文字列の配列は、useForm
の戻り値のform
に含まれるerrors
で参照することができます。
// form.tsx
"use client";
// ...
export function LoginForm() {
// ...
return (
<form id={form.id} onSubmit={form.onSubmit} action={action} noValidate>
{/* ... */}
{form.errors && (
<div>
<h2>Error:</h2>
<ul>
{form.errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
</div>
)}
</form>
);
}
バリデーションだけでなく、サーバー側エラーメッセージの受け渡しも簡単に実装できました。
a11yの改善
getFormPropsやgetInputPropsを利用することで、冗長な記述を省略したりa11y関連の属性を適切に設定することができます。
// form.tsx
export function LoginForm() {
// ...
return (
<form {...getFormProps(form)}>
<div>
<label htmlFor={fields.email.id}>Email</label>
<input {...getInputProps(fields.email, { type: "email" })} />
<div>{fields.email.errors}</div>
</div>
<div>
<label htmlFor={fields.password.id}>Password</label>
<input {...getInputProps(fields.password, { type: "password" })} />
<div>{fields.password.errors}</div>
</div>
<button type="submit">Login</button>
{/* ... */}
</form>
);
}
感想
これまではServer Actionsを使って実装するとやはり、バリデーションやエラーハンドリングの設計・実装が面倒だと感じることが多かったので、conformによって本格的にServer Actionsを使うメリットが大きくなるのではないかと感じました。実際に使ってみても、コンセプト・使い勝手ともに良さそうに思いました。
Server Actionsを使ってる方でformライブラリを探している方は、ぜひconformを検討することをお勧めします。
Discussion