パターン
よくある組み立て方をまとめます。扱うのは、複数ステップのフォーム、@k8ordo/state と組む GET フォーム、@k8ordo/ui のコンポーネントとの組み合わせ、そしてこのパッケージがまだできないことです。
複数ステップのフォーム
すべてのステップを描いたまま、今のステップ以外を隠します。値は DOM にあるので、ステップを行き来しても何も失われず、送信は最後に 1 回で済みます。
- ステップを隠すのは、ハイドレーションが終わってからにします。サーバーが返す HTML の時点で隠すと、JavaScript の無い人は後のステップにたどり着けません。隠さずにおけば、JavaScript が無いときは 1 枚の長いフォームになり、1 回のリクエストで送信されます。それが正しい動きです。
- 次に進む前に、今のステップの中の入力要素だけを
checkValidity()で確かめます。まだ入力していない後のステップまで含めると、必ず失敗するからです。 useFormがメッセージを出すのは欄を離れたときなので、触っていない欄はcheckValidity()が失敗しても何も表示しません。最初の無効な欄にフォーカスを移すと、その欄を離れたときにメッセージが出ます。- 送信ボタンは、ハイドレーションが終わったあとは最後のステップでだけ描きます。テキスト欄で Enter を押すと、ブラウザはフォームの最初の送信ボタンを押したものとして扱い、そのボタンが隠れていても送信することがあります。前のステップから、ステップの確認を飛ばしてフォーム全体が送られてしまいます。
- 送信に失敗すると、
useFormはstate.errorsの先頭の欄にフォーカスを移しますが、隠れたステップの欄はフォーカスを受け取れません。新しい結果が届いたら、描画の中で先頭のエラーを含むステップに戻します。
// src/routes/join/_parts/join-schema.ts
import * as z from 'zod';
export const joinSchema = z.object({
email: z.email('Enter your email address'),
password: z
.string()
.min(8, 'Use at least 8 characters')
.meta({ input: 'password' }),
name: z.string().min(1, 'Enter your name'),
city: z.string().max(80, 'Use 80 characters or fewer'),
});// src/routes/join/_parts/join-form.tsx
'use client';
import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState, useRef, useState, useSyncExternalStore } from 'react';
import { join } from './actions';
const ACCOUNT_FIELDS: readonly string[] = ['email', 'password'];
const subscribe = () => () => {};
type Props = {
fields: FormFields<'email' | 'password' | 'name' | 'city', never>;
};
export function JoinForm({ fields }: Props) {
const [state, formAction] = useActionState(join, {});
const form = useForm(fields, state);
const hydrated = useSyncExternalStore(
subscribe,
() => true,
() => false,
);
const [step, setStep] = useState(0);
const [answered, setAnswered] = useState(state);
const account = useRef<HTMLFieldSetElement>(null);
if (answered !== state) {
setAnswered(state);
const first = Object.keys(state.errors ?? {})[0];
if (first !== undefined) {
setStep(ACCOUNT_FIELDS.includes(first) ? 0 : 1);
}
}
const next = () => {
const invalid = [
...(account.current?.querySelectorAll('input') ?? []),
].find((control) => !control.checkValidity());
if (invalid === undefined) {
setStep(1);
} else {
invalid.focus();
}
};
const email = form.field('email');
const password = form.field('password');
const name = form.field('name');
const city = form.field('city');
return (
<form {...form.props} action={formAction}>
<fieldset hidden={hydrated && step !== 0} ref={account}>
<legend>Account</legend>
<input {...email.input} aria-label="Email" />
{email.error !== undefined && <p>{email.error}</p>}
<input {...password.input} aria-label="Password" />
{password.error !== undefined && <p>{password.error}</p>}
{hydrated && (
<button onClick={next} type="button">
Next
</button>
)}
</fieldset>
<fieldset hidden={hydrated && step !== 1}>
<legend>Profile</legend>
<input {...name.input} aria-label="Name" />
{name.error !== undefined && <p>{name.error}</p>}
<input {...city.input} aria-label="City" />
{city.error !== undefined && <p>{city.error}</p>}
{hydrated && (
<button
onClick={() => {
setStep(0);
}}
type="button"
>
Back
</button>
)}
{(!hydrated || step === 1) && <button type="submit">Join</button>}
</fieldset>
</form>
);
}@k8ordo/state と組む GET フォーム
検索や絞り込みのフォームは GET フォームです。@k8ordo/state の definePageState の url スキーマを formFields に渡せば、同じスキーマがフォームの制約と URL の状態の両方の出所になります。
// src/routes/products/_parts/list-state.ts
import { definePageState } from '@k8ordo/state';
import * as z from 'zod/mini';
export const listState = definePageState('product-list', {
url: z.object({
q: z._default(z.string(), ''),
min: z._default(z.coerce.number().check(z.int(), z.gte(0)), 0),
}),
});このスキーマは useAppState がブラウザで使うので、クライアントのバンドルに入ります。zod/mini を選ぶのはこういうときです。
// src/routes/products/page.tsx
import { formFields } from '@k8ordo/form/server';
import { FilterForm } from './_parts/filter-form';
import { listState } from './_parts/list-state';
const filterFields = formFields(listState.url);
export default function ProductsPage() {
return <FilterForm fields={filterFields} />;
}受け取る Server Action が無いので、state を省いて useForm(fields) と呼びます。送信すると値は URL の search params に着地し、useAppState が同じスキーマで読み返します。欄の初期値には、今の状態を defaultValue で渡します。
// src/routes/products/_parts/filter-form.tsx
'use client';
import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useAppState } from '@k8ordo/state';
import { listState } from './list-state';
type Props = {
fields: FormFields<'q' | 'min', never>;
};
export function FilterForm({ fields }: Props) {
const form = useForm(fields);
const q = form.field('q');
const min = form.field('min');
const [current] = useAppState(listState);
return (
<form {...form.props} method="get">
<input {...q.input} aria-label="Keyword" defaultValue={current.q} />
<input {...min.input} aria-label="Minimum" defaultValue={current.min} />
{min.error !== undefined && <p>{min.error}</p>}
<button type="submit">Filter</button>
</form>
);
}@k8ordo/router の下では、同じ pathname への GET フォームの送信はルーターが intercept し、ページの読み込みではなく状態の更新として扱います。JavaScript が無くても、同じフォームは同じ URL に着きます。
@k8ordo/ui と組み合わせる
@k8ordo/form は @k8ordo/ui に依存しません。属性は入力要素に、エラーは FormControl に渡します。FormControl は id と aria-* の結び付けを自分で作るので、渡すのは label・errorText・invalid・required だけです。
// src/routes/settings/_parts/account-schema.ts
import * as z from 'zod';
export const accountSchema = z.object({
displayName: z
.string()
.min(1, 'Enter a display name')
.max(50, 'Use 50 characters or fewer'),
website: z.url('Enter a URL'),
plan: z.enum(['personal', 'business'], 'Choose a plan'),
bio: z.string().max(1000, 'Use 1000 characters or fewer'),
password: z
.string()
.min(8, 'Use at least 8 characters')
.meta({ input: 'password' }),
});// src/routes/settings/_parts/account-form.tsx
'use client';
import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import {
Button,
FormControl,
PasswordInput,
Select,
TextField,
Textarea,
} from '@k8ordo/ui';
import { useActionState } from 'react';
import { saveAccount } from './actions';
const PLANS = [
{ value: 'personal', label: 'Personal' },
{ value: 'business', label: 'Business' },
];
type Props = {
fields: FormFields<
'displayName' | 'website' | 'plan' | 'bio' | 'password',
never
>;
};
export function AccountForm({ fields }: Props) {
const [state, formAction] = useActionState(saveAccount, {});
const form = useForm(fields, state);
const displayName = form.field('displayName');
const website = form.field('website');
const plan = form.field('plan');
const bio = form.field('bio');
const password = form.field('password');
const { type: _displayNameType, ...displayNameInput } = displayName.input;
const { type: _websiteType, ...websiteInput } = website.input;
const { type: _bioType, ...bioInput } = bio.input;
const { type: _passwordType, ...passwordInput } = password.input;
return (
<form {...form.props} action={formAction}>
<FormControl
errorText={displayName.error}
invalid={displayName.invalid}
label="Display name"
renderInput={(props) => <TextField {...props} {...displayNameInput} />}
required={displayName.required}
/>
<FormControl
errorText={website.error}
invalid={website.invalid}
label="Website"
renderInput={(props) => (
<TextField {...props} {...websiteInput} type="url" />
)}
required={website.required}
/>
<FormControl
errorText={plan.error}
invalid={plan.invalid}
label="Plan"
renderInput={(props) => (
<Select {...props} {...plan.input} options={PLANS} />
)}
required={plan.required}
/>
<FormControl
errorText={bio.error}
invalid={bio.invalid}
label="Bio"
renderInput={(props) => <Textarea {...props} {...bioInput} />}
required={bio.required}
/>
<FormControl
errorText={password.error}
invalid={password.invalid}
label="Current password"
renderInput={(props) => (
<PasswordInput {...props} {...passwordInput} />
)}
required={password.required}
/>
<Button type="submit">Save</Button>
</form>
);
}TextFieldが受け付けるtypeはtext・email・tel・url・searchだけで、input.typeはstringなので、そのまま広げると型エラーになります。typeを外して残りを広げ、typeは自分で書きます。PasswordInputは表示を切り替えるためにtypeを自分で書き換えます。導かれたtypeを広げると切り替えが効かなくなり、しかも型エラーにはならないので、必ず外します。<textarea>にもtype属性は無いので、Textareaでも外します。z.enum()から導かれたinputはtypeを持たないので、Selectにはそのまま広げられます。選択肢はoptionsで渡します。NumberFieldは値を React の state に持ち、type="text"の入力を描いて、触られていなくても0を送ります。空の数値欄を未入力として扱う約束も、導かれたrequiredも働かないので、数値には素の<input>に属性を広げます。
// src/routes/settings/_parts/number-control.tsx
'use client';
import type { FieldView } from '@k8ordo/form';
import { FormControl } from '@k8ordo/ui';
type Props = {
field: FieldView;
label: string;
};
export function NumberControl({ field, label }: Props) {
return (
<FormControl
errorText={field.error}
invalid={field.invalid}
label={label}
renderInput={({ invalid, ...props }) => (
<input {...props} {...field.input} aria-invalid={invalid} />
)}
required={field.required}
/>
);
}この節で扱ったのは TextField・PasswordInput・Textarea・Select・NumberField だけです。チェックボックス、ラジオボタン、ファイルは、フィールドのページにある素の要素で描きます。たとえば Radio は required を中の入力要素に渡しません。
まだできないこと
現在のバージョンでは、次のことはできません。
- 1 つの欄で複数のファイルを受けること。
z.array(z.file())はmultipleの付いた 1 つの欄ではなく 1 ファイルずつの繰り返し行になり、parseFormは 1 つの名前に 1 つのファイルを期待します。 .transform()の後ろにある制約や、出力を JSON Schema で表せない.pipe()の制約を導くこと。スキーマが送信される値を語らなくなるので、欄は素のテキスト欄になりdroppedに載ります。検証はすべてサーバーで行われます。- 文字列を拒む
z.custom()を導出時に拒否すること。中身を読めないので、テキスト欄としてdroppedに載り、サーバーで失敗します。 - 1 つの欄、ネストしたオブジェクト、繰り返し行に付けた
.refine()/.superRefine()をdroppedで報告すること。報告されるのはルートのオブジェクトのチェックの件数だけで、それ以外はクライアントに何も知らせずサーバーでだけ走ります。 - 繰り返し行の中の欄を対象にしたルール。ルールの欄名はスキーマのパスで型付けされ、行の中の欄はそこに含まれません。
- 入力のマスク。DOM を正とする設計でも
el.valueを書き換えれば実現できますが、キャレットの管理はフォームの結線とは別の問題です。