Get Started
フォームの制約は zod スキーマに一度だけ書きます。@k8ordo/form はそこから、ブラウザに渡す制約属性とメッセージ、Server Action で使う型付きの検証を導きます。クライアント側の検証は手で書くものではなく、導かれるものです。
考え方
フォームの検証は、ふつう 2 回書かれます。JSX の required や maxLength と、サーバーの検証コードです。2 つは別々に直されるので、いずれ食い違います。このパッケージは書く場所を 1 つにして、残りを導きます。
- スキーマが唯一の出所です。
required・minLength・type="email"といった属性も、欄の横に出すメッセージも、サーバーでの検証も、同じスキーマから来ます。 - 値は DOM が持ちます。React の state に載るのは、表示中のメッセージ、まだ有効なサーバーのエラー、繰り返し行の識別子、変更の有無を表す真偽値 1 つ、行の追加・削除を比べる基準の行数だけです。値を state に写さないので、キーを押すたびにフォームが描き直されることはありません。
- JavaScript が無くても動きます。制約属性はサーバーが返す HTML に入っているので、スクリプトが無効でも読み込み前でも、ブラウザ自身の検証が働きます。
- 最後に決めるのはサーバーです。HTML の属性で表せない検証もあるので、
parseFormが送信のたびに同じスキーマで検証し、欄ごとのエラーと入力値を返します。
インストール
@k8ordo/form と、スキーマを書くための zod を追加します。
npm install @k8ordo/form zodpeer dependencies は次のとおりです。TypeScript と @types/react は、同梱の型定義を使うときにだけ必要です。
| パッケージ | バージョン | 用途 |
|---|---|---|
react | >=19.3.0 | useForm と 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 段で組み立てます。
- Server Component か、そのモジュールスコープで
formFields(schema)を呼びます。結果は属性・メッセージ・ルール・droppedからなる JSON で、関数も zod も含みません。 - その結果を props でクライアントコンポーネントに渡し、
useForm(fields, state)に渡します。JSON なので RSC の境界を越えられ、zod はクライアントのバンドルに入りません。 - 送信は Server Action が受け、
parseForm(schema, formData)が型付きのデータか、欄ごとのエラーを含むstateを返します。
| 入口 | 値 | 型 |
|---|---|---|
@k8ordo/form/server | formFields, parseForm, defineForm, sameAs, minChecked, requiredWhen | ParseResult, FormDefinition |
@k8ordo/form | useForm, useAsyncCheck, HiddenValue | UseFormReturn, 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');
}4. フォーム(クライアントコンポーネント)
useForm は UseFormReturn(props・field・array・isDirty)を返します。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.props は ref・onBlur・onInput・onReset の 4 つです。同じ名前の props を <form> に自分でも書くと、あとに書いたほうだけが効き、useForm の検証やリセットの処理が外れることがあります。
パスを props の型に手で並べたくなければ、スキーマと formFields を import 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の先頭にある欄にフォーカスが移ります。