バリデーション
検証は 1 つのスキーマから 2 か所で走ります。ブラウザは制約属性を検査して zod の文言を出し、Server Action が最終的に決めます。このページでは、両側が何を保証するか、メッセージが出て消えるまでの流れ、parseForm の結果、複数の欄にまたがる検証、サーバーに問い合わせる検証を扱います。
ブラウザとサーバーの二段構え
どちらも同じスキーマを読むので、useForm が出す文言は、そのチェックに対する zod 自身の文言で、サーバーの parseForm がそのチェックに使う文言と同じです。違うのは、いつ、どこまで検査するかです。ブラウザは属性やルールになった検査だけを走らせ、1 つの値が複数のチェックに落ちるときは、両側が先に挙げるチェックが違うことがあります。
- JavaScript が無いとき、制約属性はそのままブラウザの制約検証として働きます。通らない送信はブラウザが止め、文言はブラウザのものです。
- JavaScript が動くと、
form.propsのrefがフォームにnoValidateを付けます。ブラウザは送信を止めなくなり、代わりにuseFormが zod の文言を表示します。noValidateはマークアップに書かれないので、スクリプトが読み込まれるまではブラウザの検証が残ります。 - そのため JavaScript が動いているときの送信は、ブラウザ側のメッセージにかかわらず Server Action に届きます。HTML で表せない検証(
droppedに載ったもの)はサーバーでしか走らないので、決めるのはサーバーです。
触って確かめる
下のフォームは、このページの Server Component で formFields(defineForm(…)) が導いたデータを useForm に渡しています。ハンドルは 3 文字以上 20 文字以下で英小文字・数字・_ だけ、パスワードは 8 文字以上、確認欄には sameAs のルールが付いています。
- ハンドルに 2 文字だけ入れて欄を離れると、メッセージが出ます。
- 戻って入力を続けると、表示中のメッセージが値に合わせて変わり、条件を満たした時点で消えます。まだメッセージの無い欄に、入力の途中で新しく出ることはありません。
- 確認欄にパスワードと違う値を入れて離れると、
sameAsの文言が出ます。パスワードの側を直すと、確認欄に触らなくても消えます。 - 「リセット」を押すと、値と一緒にメッセージが消え、
isDirtyがfalseに戻ります。
このサイトは @k8ordo/static で静的に書き出されていて Server Action が無いので、送信ボタンは置いていません。実際のフォームでは、ここから先をサーバーが引き受けます。
クライアントに渡っているデータの一部です。文言はこのページの言語で導かれています。
{
"fields": {
"handle": {
"input": {
"name": "handle",
"required": true,
"type": "text",
"minLength": 3,
"maxLength": 20,
"pattern": "^[a-z0-9_]+$"
},
"messages": {
"valueMissing": "3 文字以上で入力してください",
"tooShort": "3 文字以上で入力してください",
"tooLong": "20 文字以内で入力してください",
"patternMismatch": "英小文字・数字・_ で入力してください"
},
"secret": false
}
},
"rules": [
{
"kind": "sameAs",
"field": "confirm",
"other": "password",
"message": "パスワードが一致しません"
}
]
}メッセージが出て消えるまで
useForm はブラウザの ValidityState を読み、失敗しているフラグに対応するスキーマの文言を出します。いつ出して、いつ消すかは次のとおりです。
- 欄を離れたとき(blur)に値が無効なら、メッセージが出ます。
- 入力中は、すでに表示されているメッセージだけを更新し、値が有効になれば消します。入力の途中で新しいメッセージを出すことはありません。
:user-invalidと同じ考え方です。 - どの文言を出すかは、
setCustomValidityで付いた文言(ルールやuseAsyncCheckの答え)が最優先で、その後はValidityFlagのvalueMissing・typeMismatch・patternMismatch・tooShort・tooLong・rangeUnderflow・rangeOverflow・stepMismatch・badInputの順です。 - ルールの対象の欄は、どの欄のイベントでも読み直されます。別の欄を直して解消した違反は、その場で消えます。ただし、まだメッセージの出ていない欄に、この経路で新しく出すことはありません。
- Server Action が返した
state.errorsのエラーは、その欄が編集されるまで表示されます。ブラウザ側のメッセージがあれば、そちらが優先されます。 - Server Action から新しい結果が届くと、ブラウザ側のメッセージと「編集済み」の記録を捨て、行数を
state.rowsから作り直し、state.errorsの先頭の欄にフォーカスを移します。スクリーンリーダーの利用者が、送信が失敗したことと、その場所を知るためです。 - 結果は内容と
tokenで比べます。同じ失敗が 2 回続いてもtokenが違うので新しい答えとして扱い、編集して消えていたエラーをもう一度表示します。parseFormを通さずにFormStateを自分で組み立てるときは、新しいtokenも入れます。入れなければ、同じ内容の 2 回目の失敗は同じ答えとして読まれます。
リセット
form.props の onReset はフォームのリセットを受け取ります。リセットボタン、form.reset()、action が終わったあと(何を返したかにかかわらず)React 自身が行うリセットのどれでも、古い値について覚えていたことを捨てます。
- ブラウザ側のメッセージと、ルールが
setCustomValidityで付けた文言を消します。 - 「編集済み」の記録を消すので、今の
state.errorsにあるサーバーのエラーはもう一度表示されます。 - 行数を、今の
state.rows(無ければ.min())に戻します。 isDirtyは、ブラウザが値を戻し終えたあとに DOM から読み直します。
React は action のあと、失敗を返したときにもフォームをリセットします。それでも入力が残るのは、state.values が defaultValue として描かれ、リセットがその値に戻すからです。パスワードは返されないので空になります。
変更の有無: isDirty
form.isDirty は、どれかの欄が描画されたときの値と違えば true です。値を追いかけるのではなく DOM から読み直すので、この値が再描画を起こすのは真偽値が変わるときだけです。比べるものは次のとおりです。
- テキスト欄と
<textarea>はvalueとdefaultValue - チェックボックスとラジオボタンは
checkedとdefaultChecked <select>は各<option>のselectedとdefaultSelectedHiddenValueはマウントしたときの値- 繰り返し行は行数
リセットは HiddenValue を戻しません。値は呼び出し側の state で、React がそのまま書き戻すからです。編集された HiddenValue を持つフォームは、その state を戻すまで isDirty のままです。
サーバーが描く HTML では isDirty は常に false です。送信ボタンを disabled={!form.isDirty} にすると、JavaScript の無い人は送信できなくなります。表示や、ページを離れるときの確認に使います。
parseForm の結果
parseForm(schema, formData) は FormData をスキーマの形に組み立て直してから検証します。組み立てるのは構造だけ(ネスト、繰り返しの名前、チェックされていないボックス)で、'42' を 42 にするような型の変換はスキーマの z.coerce に任せます。
parseFormはParseResultを返します。成功すれば{ success: true, data, state }です。dataはスキーマの出力型です。- 失敗すれば
{ success: false, state }です。stateを Server Action からそのまま返します。
FormState の中身
| メンバー | 型 | 意味 |
|---|---|---|
errors? | Record<string, string> | 欄の name をキーにしたエラー。1 つの欄に複数の問題があれば最初のものだけを入れ、同じ欄にルールの違反があればそちらを入れます(違反が複数なら、ブラウザと同じく先に宣言したルールです)。 |
values? | Record<string, string | string[]> | 送られてきた値。やり直しで入力を戻すためのもので、パスワードとして印を付けた欄とファイルは入りません。チェックボックス群はチェックの数にかかわらず配列で、それ以外でも同じ名前で複数の値が送られれば配列になります。 |
rows? | Record<string, number> | 繰り返し行ごとの行数。JavaScript の有無にかかわらず、やり直しで同じ行数を描くためのものです。 |
formError? | string | どの欄にも属さない問題。path を持たないルートの .refine() などです。useForm は表示しないので、state.formError を自分で描きます。 |
token? | string | 1 回の検証の識別子。同じ内容の結果が 2 回返っても、クライアントが区別できるようにするためのものです。 |
parseForm は、スキーマにある欄の名前が FormData に 1 つも無いと例外を投げます。input を広げ忘れたか、その欄を描いていないということで、入力した人の誤りではなく結線の誤りだからです。ただし、触られないと何も送らない入力要素は対象外です。何も選ばれていないラジオボタンは、プレースホルダーのままの <select> と同じく値が無いものとしてスキーマに渡り(undefined を受け付けない enum なら検証エラー)、チェックされていないチェックボックスは false、何もチェックされていないチェックボックス群は [] として届くので、これらで広げ忘れても例外にはなりません。
サーバーにしか分からない検証
データベースに問い合わせないと分からない検証(ハンドルがすでに使われているかどうかなど)は、parseForm が成功したあとで行い、同じ FormState の形で返します。parsed.state を広げれば、入力値と token も一緒に返ります。
// src/routes/settings/_parts/handle-schema.ts
import * as z from 'zod';
export const handleSchema = z.object({
handle: z
.string()
.min(3, 'Use at least 3 characters')
.max(20, 'Use 20 characters or fewer')
.regex(/^[a-z0-9_]+$/u, 'Use lowercase letters, digits and _'),
});// src/routes/settings/_parts/actions.ts
'use server';
import { parseForm } from '@k8ordo/form/server';
import type { FormState } from '@k8ordo/form/server';
import { isHandleTaken, saveHandle } from './accounts.server';
import { handleSchema } from './handle-schema';
export async function changeHandle(
_previous: FormState,
formData: FormData,
): Promise<FormState> {
const parsed = parseForm(handleSchema, formData);
if (!parsed.success) return parsed.state;
if (await isHandleTaken(parsed.data.handle)) {
return { ...parsed.state, errors: { handle: 'That handle is taken' } };
}
await saveHandle(parsed.data.handle);
return {};
}
export async function checkHandle(
handle: string,
): Promise<string | undefined> {
return (await isHandleTaken(handle)) ? 'That handle is taken' : undefined;
}複数の欄にまたがる検証: defineForm
パスワードの確認、「2 つ以上選ぶ」、「法人プランのときだけ会社名が必須」のような検証には、対応する制約属性がありません。.refine() は関数なのでクライアントに渡せません。そこで、スキーマの横にルールをデータとして宣言します。
// src/routes/signup/_parts/signup-definition.ts
import {
defineForm,
minChecked,
requiredWhen,
sameAs,
} from '@k8ordo/form/server';
import * as z from 'zod';
export const signup = defineForm(
z.object({
password: z
.string()
.min(8, 'Use at least 8 characters')
.meta({ input: 'password' }),
confirm: z.string().meta({ input: 'password' }),
plan: z.enum(['personal', 'business'], 'Choose a plan'),
company: z.string().max(100, 'Use 100 characters or fewer'),
topics: z.array(z.enum(['react', 'css', 'a11y'])),
}),
[
sameAs('confirm', 'password', 'The passwords do not match'),
requiredWhen(
'company',
'plan',
'business',
'Enter your company for a business plan',
),
minChecked('topics', 2, 'Pick at least two topics'),
],
);スキーマを渡していた場所に定義を渡します。defineForm(schema, rules) は FormDefinition を返し、sameAs・minChecked・requiredWhen はそれぞれただのデータである Rule を返します。formFields(signup) がルールをデータとしてクライアントに運び、parseForm(signup, formData) がサーバーで評価します。両側は同じ評価関数を使うので、2 つ目の実装がずれていくことはありません。
// src/routes/signup/_parts/signup-form.tsx
'use client';
import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';
import { signUp } from './actions';
const TOPICS = ['react', 'css', 'a11y'] as const;
type Props = {
fields: FormFields<
'password' | 'confirm' | 'plan' | 'company' | 'topics',
never
>;
};
export function SignupForm({ fields }: Props) {
const [state, formAction] = useActionState(signUp, {});
const form = useForm(fields, state);
const password = form.field('password');
const confirm = form.field('confirm');
const plan = form.field('plan');
const company = form.field('company');
const topics = form.field('topics');
const echoed = state.values?.topics ?? [];
const checked = typeof echoed === 'string' ? [echoed] : echoed;
return (
<form {...form.props} action={formAction}>
<input {...password.input} aria-label="Password" />
{password.error !== undefined && <p>{password.error}</p>}
<input {...confirm.input} aria-label="Confirm password" />
{confirm.error !== undefined && <p>{confirm.error}</p>}
<select {...plan.input} aria-label="Plan">
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
<input {...company.input} aria-label="Company" />
{company.error !== undefined && <p>{company.error}</p>}
<fieldset>
<legend>Topics</legend>
{TOPICS.map((option) => (
<label key={option}>
<input
defaultChecked={checked.includes(option)}
name={topics.input.name}
type="checkbox"
value={option}
/>
{option}
</label>
))}
</fieldset>
{topics.error !== undefined && <p>{topics.error}</p>}
<button type="submit">Sign up</button>
</form>
);
}// src/routes/signup/_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 { createAccount } from './accounts.server';
import { signup } from './signup-definition';
export async function signUp(
_previous: FormState,
formData: FormData,
): Promise<FormState> {
const parsed = parseForm(signup, formData);
if (!parsed.success) return parsed.state;
await createAccount(parsed.data);
redirect('/welcome');
}| ルール | 違反になるとき |
|---|---|
sameAs(field, other, message) | field の値が other の値と違うとき |
minChecked(field, min, message) | field の名前で送られる値が min 個より少ないとき |
requiredWhen(field, when, equals, message) | when の値が equals と等しく、field が空のとき |
- ブラウザでは、違反を
setCustomValidityで欄に付けます。組み込みの検証と区別がつかないので:user-invalidにも一致し、文言はほかのメッセージと同じ経路で出ます。 - ルールは送られる文字列を比べます。
sameAsとrequiredWhenはその名前の最初の値を読み、何も送られていなければ''として扱います。チェックされたチェックボックスはvalue(既定はon)を送ります。 - ルールはサーバーでも評価されるので、スキーマ側に
.min(2)を重ねて書く必要はありません。例のtopicsも.min(2)を持っていません。 - ルールの欄名はスキーマのパスで型付けされます。繰り返し行の中の欄はそのパスに含まれないので、ルールの対象にはできません。
- これ以外の検証は
.refine()のまま、サーバーでだけ走ります。ルートのオブジェクトの.refine()はdroppedに件数が載り、その問題はpathを付ければその欄のエラーに、付けなければstate.formErrorになります。
サーバーに問い合わせる検証: useAsyncCheck
ハンドルが使われているかどうかは、サーバーにしか分かりません。useAsyncCheck(check) は欄を離れたときに check を呼び、答えを setCustomValidity で欄に付けます。答えはほかのメッセージと同じ経路で表示されます。
useAsyncCheck は AsyncCheck を返します。check は値を受け取り、問題があれば文言を、無ければ undefined を返す非同期関数で、Server Action をそのまま渡せます。戻り値の props(onBlur と ref)を入力要素に広げます。isChecking は答えを待っている間 true です。
// src/routes/settings/_parts/handle-form.tsx
'use client';
import { useAsyncCheck, useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';
import { changeHandle, checkHandle } from './actions';
type Props = {
fields: FormFields<'handle', never>;
};
export function HandleForm({ fields }: Props) {
const [state, formAction] = useActionState(changeHandle, {});
const form = useForm(fields, state);
const handle = form.field('handle');
const taken = useAsyncCheck(checkHandle);
return (
<form {...form.props} action={formAction}>
<label>
Handle
<input {...handle.input} {...taken.props} />
</label>
{handle.error !== undefined && <p>{handle.error}</p>}
<button disabled={taken.isChecking} type="submit">
Save
</button>
</form>
);
}- 答えが順不同で届いても、最後の問い合わせの答えだけを使います。欄の値が問い合わせたときから変わっていれば、その答えは捨てます。
- 最後に答えを得た値のまま欄を離れても問い合わせません。問い合わせ中に離れたときは、もう一度問い合わせます。
- 欄を空にして離れると、前の答えも消えます。
checkが例外を投げたときは、どちらの判断も付けません。- アンマウントしたあとに届いた答えは使いません。
useAsyncCheck は答えを早めに見せるためのもので、parseForm からは呼ばれません。上の例のように、Server Action でも同じ確認を行います。