@k8ordo/form

Get Started

フォームの制約は zod スキーマに一度だけ書きます。@k8ordo/form はそこから、ブラウザに渡す制約属性とメッセージ、Server Action で使う型付きの検証を導きます。クライアント側の検証は手で書くものではなく、導かれるものです。

考え方

フォームの検証は、ふつう 2 回書かれます。JSX の requiredmaxLength と、サーバーの検証コードです。2 つは別々に直されるので、いずれ食い違います。このパッケージは書く場所を 1 つにして、残りを導きます。

  • スキーマが唯一の出所です。requiredminLengthtype="email" といった属性も、欄の横に出すメッセージも、サーバーでの検証も、同じスキーマから来ます。
  • 値は DOM が持ちます。React の state に載るのは、表示中のメッセージ、まだ有効なサーバーのエラー、繰り返し行の識別子、変更の有無を表す真偽値 1 つ、行の追加・削除を比べる基準の行数だけです。値を state に写さないので、キーを押すたびにフォームが描き直されることはありません。
  • JavaScript が無くても動きます。制約属性はサーバーが返す HTML に入っているので、スクリプトが無効でも読み込み前でも、ブラウザ自身の検証が働きます。
  • 最後に決めるのはサーバーです。HTML の属性で表せない検証もあるので、parseForm が送信のたびに同じスキーマで検証し、欄ごとのエラーと入力値を返します。

インストール

@k8ordo/form と、スキーマを書くための zod を追加します。

npm install @k8ordo/form zod

peer dependencies は次のとおりです。TypeScript と @types/react は、同梱の型定義を使うときにだけ必要です。

パッケージバージョン用途
react>=19.3.0useForm と Server Action
react-dom>=19.3.0描画
zod^4.4.3スキーマ(zod/mini でも可)
typescript>=7.0.2同梱の型定義(任意)
@types/react>=19.3.0同梱の型定義(任意)

zod か zod/mini か

変換は zod の共有コアから読むので、どちらの入口で書いたスキーマでも動きます。パッケージはどちらが選ばれたかを区別しません。スキーマを import するのは Server Component と Server Action だけなので、どちらを選んでも zod はブラウザに届きません。スキーマのモジュールをクライアントからも import するとき(@k8ordo/state の GET フォームなど)は、zod/mini を選ぶとバンドルが小さく済みます。

import * as z from 'zod';

export const talkSchema = z.object({
  title: z
    .string()
    .min(1, 'Enter a title')
    .max(120, 'Use 120 characters or fewer'),
});
import * as z from 'zod/mini';

export const talkSchema = z.object({
  title: z
    .string()
    .check(
      z.minLength(1, 'Enter a title'),
      z.maxLength(120, 'Use 120 characters or fewer'),
    ),
});

zod/mini はロケールを同梱しないので、既定のメッセージは Invalid input です。ただし同じプロセスで zod の入口も使われていると、その英語のロケールが全体に効きます。欄の横に出る文言も zod のものなので、メッセージを自分で書くか、z.config(z.locales.ja()) のようにロケールを読み込みます。

サーバーとクライアントの分担

入口は 2 つあります。@k8ordo/form/server はスキーマを読む側、@k8ordo/form はブラウザで動くフックの側です。1 つのフォームは次の 3 段で組み立てます。

  1. Server Component か、そのモジュールスコープで formFields(schema) を呼びます。結果は属性・メッセージ・ルール・dropped からなる JSON で、関数も zod も含みません。
  2. その結果を props でクライアントコンポーネントに渡し、useForm(fields, state) に渡します。JSON なので RSC の境界を越えられ、zod はクライアントのバンドルに入りません。
  3. 送信は Server Action が受け、parseForm(schema, formData) が型付きのデータか、欄ごとのエラーを含む state を返します。
入口
@k8ordo/form/serverformFields, parseForm, defineForm, sameAs, minChecked, requiredWhenParseResult, FormDefinition
@k8ordo/formuseForm, useAsyncCheck, HiddenValueUseFormReturn, FieldView, ArrayView, RowView, AsyncCheck
両方FormFields, FormState, DerivedField, DerivedArray, DroppedCheck, FieldInput, ValidityFlag, Rule

formFields の結果と Server Action の state の型は、両方の入口から export されています。結果は props としてクライアントにも現れるからです。

フォームを 1 つ作る

講演を登録するフォームを、スキーマ・ページ・Server Action・フォームの 4 ファイルで書きます。例は Server Action を持つ @k8ordo/server のアプリです。

1. スキーマ

型の変換はスキーマに書きます。送信される値はすべて文字列なので、数値は z.coerce.number() で受けます。空の数値欄は 0 ではなく「未入力」として届くので、未入力のときの文言は z.coerce.number() の引数に書きます。zod の版によっては、この文言が自分の文言を持たないチェックにも使われます(4.5.4 は使い、4.4.3 は使いません)。そのため .int().min() にはそれぞれの文言を書きます。

// src/routes/talks/new/_parts/talk-schema.ts
import * as z from 'zod';

export const talkSchema = z.object({
  title: z
    .string()
    .min(1, 'Enter a title')
    .max(120, 'Use 120 characters or fewer'),
  eventUrl: z.url('Enter the event URL'),
  minutes: z.coerce
    .number('Enter the length in minutes')
    .int('Use whole minutes')
    .min(5, 'A talk is at least 5 minutes'),
  recorded: z.boolean(),
});

2. ページ(Server Component)

formFields はモジュールスコープで一度だけ呼びます。結果はどのリクエストでも同じなので、描画のたびに導き直す必要はありません。メッセージをリクエストの言語に合わせたいときだけ、描画の中で呼びます。

// src/routes/talks/new/page.tsx
import { formFields } from '@k8ordo/form/server';

import { TalkForm } from './_parts/talk-form';
import { talkSchema } from './_parts/talk-schema';

const talkFields = formFields(talkSchema);

export default function NewTalkPage() {
  return <TalkForm fields={talkFields} />;
}

3. Server Action

parseForm が失敗したら、parsed.state をそのまま返します。欄ごとのエラーと入力値(パスワードとファイルは除く)が入っているので、JavaScript の有無にかかわらず、やり直しても入力は消えません。成功すれば parsed.data はスキーマの出力型です。

// src/routes/talks/new/_parts/actions.ts
'use server';

import { parseForm } from '@k8ordo/form/server';
import type { FormState } from '@k8ordo/form/server';
import { redirect } from '@k8ordo/server/runtime';

import { talkSchema } from './talk-schema';
import { insertTalk } from './talks.server';

export async function createTalk(
  _previous: FormState,
  formData: FormData,
): Promise<FormState> {
  const parsed = parseForm(talkSchema, formData);
  if (!parsed.success) return parsed.state;
  await insertTalk(parsed.data);
  redirect('/talks');
}

@k8ordo/server の Server Action

4. フォーム(クライアントコンポーネント)

useFormUseFormReturnpropsfieldarrayisDirty)を返します。form.props<form> に広げます。欄ごとの登録はありません。field(path) が返す input を入力要素に広げ、error があれば表示します。パスはスキーマから型で導かれるので、打ち間違いはコンパイルで止まります。props の型の FormFields<FieldPath, ArrayPath> は、1 つ目に field() のパス、2 つ目に array() のパスを取ります(無ければ never)。

// src/routes/talks/new/_parts/talk-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { createTalk } from './actions';

type Props = {
  fields: FormFields<'title' | 'eventUrl' | 'minutes' | 'recorded', never>;
};

export function TalkForm({ fields }: Props) {
  const [state, formAction] = useActionState(createTalk, {});
  const form = useForm(fields, state);
  const title = form.field('title');
  const eventUrl = form.field('eventUrl');
  const minutes = form.field('minutes');
  const recorded = form.field('recorded');

  return (
    <form {...form.props} action={formAction}>
      <label>
        Title
        <input {...title.input} aria-invalid={title.invalid} />
      </label>
      {title.error !== undefined && <p>{title.error}</p>}

      <label>
        Event URL
        <input {...eventUrl.input} aria-invalid={eventUrl.invalid} />
      </label>
      {eventUrl.error !== undefined && <p>{eventUrl.error}</p>}

      <label>
        Minutes
        <input {...minutes.input} aria-invalid={minutes.invalid} />
      </label>
      {minutes.error !== undefined && <p>{minutes.error}</p>}

      <label>
        <input {...recorded.input} />
        Recorded
      </label>

      {state.formError !== undefined && <p>{state.formError}</p>}
      <button type="submit">Register</button>
    </form>
  );
}

form.propsrefonBluronInputonReset の 4 つです。同じ名前の props を <form> に自分でも書くと、あとに書いたほうだけが効き、useForm の検証やリセットの処理が外れることがあります。

パスを props の型に手で並べたくなければ、スキーマと formFieldsimport type で読み、ReturnType<typeof formFields<typeof talkSchema>> と書けます。型だけの import なので、zod もスキーマもクライアントのバンドルには入りません。

// src/routes/talks/new/_parts/talk-form.tsx
'use client';

import type { formFields } from '@k8ordo/form/server';

import type { talkSchema } from './talk-schema';

type Props = {
  fields: ReturnType<typeof formFields<typeof talkSchema>>;
};

送信したときに起きること

  • JavaScript が動いていなければ、ブラウザが制約属性で検証し、通らない送信を止めます。文言はブラウザのものです。
  • JavaScript が動いていれば、useForm がフォームに noValidate を付け、欄を離れたときに zod の文言を出します。送信は止めず、そのまま Server Action に届けます。
  • parseForm が失敗を返すと、エラーが欄ごとに表示され、state.errors の先頭にある欄にフォーカスが移ります。

メッセージが出て消えるまでの流れと、parseForm の結果の詳細はバリデーションのページにあります。

次に読む