logo

SeverActions x conform x zod x shadcn/ui を使ってみた

2024-03-24
9 months ago

開発環境

  • next 14.1.0
  • @conform-to/react 1.0.5
  • @conform-to/zod 1.0.5
  • zod 3.22.4

前提

簡易なログインフォームの実装例になります。

ServerActionsを利用することを前提とした実装例になりますが、ServerActionsについては公式ドキュメントや他の参考になる記事がたくさんあるので、ここでは言及しません。

本題

Nextjsでフォームを実装するときの選択肢としてReactHookFormがよく上がると思います。

私もほとんどのプロジェクトでお世話になっています。

ただ、2024年3月時点ではServerActionsについては検証段階のようです。

そこで、ServerActionsにも対応したcomformを今回実装してみようと思います。

conformとは?

その前に簡単にconformの紹介です。

公式ドキュメントから引用ですが、以下の特徴があります。

※日本語訳しています。

  • プログレッシブエンハンスメントの最初の API
  • タイプセーフなフィールド推論
  • きめ細かいサブスクリプション
  • 組み込みのアクセシビリティ ヘルパー
  • Zod による自動型強制

実施に使用してみた感じだと、ReactHookFormに近い感覚で実装できることと、APIもシンプルなので学習コストも高くない印象です。

実装してみる

まずは完成系がこちらです。

① フォーム

'use client';

import { useForm, getFormProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { useFormState, useFormStatus } from 'react-dom';

import { Button } from '@/components/ui/button';
import { ConformCheckbox } from '@/components/ui/conform-checkbox';
import { ConformInput } from '@/components/ui/conform-input';
import { Field, FieldError } from '@/components/ui/field';
import { Label } from '@/components/ui/label';

import { login } from '../../actions/login';
import { loginSchema } from '../../schemas/login';

const SubmitButton = () => {
  const { pending } = useFormStatus();

  return (
    <Button type="submit" form="login" disabled={pending}>
      Login
    </Button>
  );
};

export const LoginForm = () => {
  const [lastResult, action] = useFormState(login, undefined);
  const [form, fields] = useForm({
    id: 'login',
    lastResult,
    defaultValue: {                 // ポイント(1)
      email: 'user1@example.com',
      password: 'Password12345!',
    },
    onValidate({ formData }) {        // ポイント(2)
      return parseWithZod(formData, { schema: loginSchema });
    },
    shouldValidate: 'onInput',       // ポイント(3)
  });

  return (
    <form
      {...getFormProps(form)}     // ポイント(4)
      action={action}
      className="mx-auto grid max-w-sm gap-4"
    >
      <Field>
        <Label htmlFor={fields.email.id}>Email</Label>
        <ConformInput meta={fields.email} type="text" />   // ポイント(5)
        <FieldError>{fields.email.errors}</FieldError>
      </Field>
      <Field>
        <Label htmlFor={fields.password.id}>Password</Label>
        <ConformInput meta={fields.password} type="password" />
        <FieldError>{fields.password.errors}</FieldError>
      </Field>
      <Field>
        <div className="flex items-center gap-2">
          <ConformCheckbox meta={fields.remember} />
          <Label htmlFor={fields.remember.id}>Remember me</Label>
        </div>
      </Field>
      <SubmitButton />
      <Button variant="outline" {...form.reset.getButtonProps()}> // ポイント(6)
        Reset
      </Button>
    </form>
  );
};
login-form.tsx

② アクション

'use server';

import { redirect } from 'next/navigation';

import { parseWithZod } from '@conform-to/zod';

import { loginSchema } from '../../schemas/login';

export async function login(prevState: unknown, formData: FormData) {
  const submission = parseWithZod(formData, {
    schema: loginSchema,
  });

  if (submission.status !== 'success') {
    return submission.reply();       // ポイント(7)
  }
  
  // 本来はここでログイン処理

  redirect('/');
}
actions.ts

③ スキーマ

import { z } from 'zod';

export const loginSchema = z.object({
  email: z.string().email(),
  password: z.string(),
  remember: z.boolean().optional().default(false),
});

ポイント

実装例でコメントアウトしたポイントについて説明します。

(1)各フィールドの初期値をここで設定します。今回のログインフォームでは必要ないですが、編集系のフォームなどでは利用することになります。

(2)onValidateはクライアント側での(再)検証用に、サーバー側と同じバリデーションを設定しています。こちらの設定はオプショナルですので省略可能ですが、ユーザーが入力するたびに再検証を行う場合は、ネットワーク遅延とユーザーがサーバーにアクセスする頻度が増えるので注意が必要です。

(3)検証のトリガーを onSubmitonBluronInputから設定することができます。デフォルトはonSubmitです。

(4)少ないコード量で実装するための便利なヘルパーがいくつか用意されています。getFormPropsを利用すると以下の例のようにコードがスッキリします。

// Before
function Example() {
  return (
    <form
      id={form.id}
      onSubmit={form.onSubmit}
      noValidate={form.noValidate}
      aria-invalid={!form.valid || undefined}
      aria-describedby={!form.valid ? form.errorId : undefined}
    />
  );
}

// After
function Example() {
  return <form {...getFormProps(form)} />;
}

(5)shadcn/uiを利用する場合はそのままでは利用できないコンポーネントもあるので注意が必要です。コード例は後述します。conformの公式ドキュメントに様々なフォーム系の実装例があるので、こちらを参考にしてください。また、その他のUIライブラリのChakra UIHeadless UIなどの実装例もあります。

(6)値のリセットAPIも用意されています。こちらのリセットを利用する際はコンポーネントにkeyを設定する必要があります(注意:スプレッド構文だとエラーが出ます)

import { ComponentProps } from 'react';

import { type FieldMetadata, getInputProps } from '@conform-to/react';

import { Input } from '../ui/input';

const ConformInput = ({
  meta,
  type,
  ...props
}: {
  meta: FieldMetadata<string>;
  type: Parameters<typeof getInputProps>[1]['type'];
} & ComponentProps<typeof Input>) => {
  return (
    <Input
      {...getInputProps(meta, { type, ariaAttributes: true })}
      {...props}
      key={meta.key} // metaDataのキーを設定する
    />
  );
};

export { ConformInput };

(7)エラー時の処理です。return submission.reply();と、非常に簡潔です。エラーの詳細の確認やzodErrorのハンドリングを自分で実装する必要がないです。

さいごに

まだまだ発展途上のライブラリですが、npm trendsで見るとダウンロード数が右肩上がりで増えているので、今後どんどん機能が充実することを期待しています。

参照