📝

Server Actions時代のformライブラリconform

2024/03/13に公開

conformプログレッシブ・エンハンスメントを意識して作られたReactのformライブラリです。

https://conform.guide/

RemixNext.jsなどのフレームワークをサポートしています。react-hook-formのServer Actions対応は現状検証段階なのですが、conformはすでにServer Actionsにも対応しています。

本稿ではNext.js(App Router)におけるconformの使い方を中心に紹介します。

Server Actions

もう散々他の記事や公式ドキュメントで紹介されていますが、簡単にServer Actionsについて復習します。

基本的な使い方

Server Actionsはクライアントサイドから呼び出すことができる、サーバー側で実行される関数です。

https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

この機能の最も一般的なユースケースは、サーバー側のデータを変更する際に呼び出すことです。ReactはJSXにおけるformタグを拡張しており、<form action={serverAction}>のようにformのactionpropsに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の再検証などの処理
}

// page.tsx
export default function Page() {
  return (
    <form action={createUser}>
      <input type="text" name="fullname" />
      ...
    </form>
  );
}

useFormState

Server Actionsの実行結果に応じて状態を変更したくなることもあるでしょう。そのような場合にはuseFormStateを利用することができます。

https://react.dev/reference/react-dom/hooks/useFormState

useFormState(action, initialState, permalink?)のように、actioninitialStateを引数に取ります。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もありますが、本稿では扱わないため割愛します。

https://react.dev/reference/react-dom/hooks/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/zodparseWithZodを利用することで以下のように書くことができます。

// 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をuseFormlastResultに渡しています。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の改善

getFormPropsgetInputPropsを利用することで、冗長な記述を省略したり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