@k8ordo/form

バリデーション

検証は 1 つのスキーマから 2 か所で走ります。ブラウザは制約属性を検査して zod の文言を出し、Server Action が最終的に決めます。このページでは、両側が何を保証するか、メッセージが出て消えるまでの流れ、parseForm の結果、複数の欄にまたがる検証、サーバーに問い合わせる検証を扱います。

ブラウザとサーバーの二段構え

どちらも同じスキーマを読むので、useForm が出す文言は、そのチェックに対する zod 自身の文言で、サーバーの parseForm がそのチェックに使う文言と同じです。違うのは、いつ、どこまで検査するかです。ブラウザは属性やルールになった検査だけを走らせ、1 つの値が複数のチェックに落ちるときは、両側が先に挙げるチェックが違うことがあります。

  • JavaScript が無いとき、制約属性はそのままブラウザの制約検証として働きます。通らない送信はブラウザが止め、文言はブラウザのものです。
  • JavaScript が動くと、form.propsref がフォームに noValidate を付けます。ブラウザは送信を止めなくなり、代わりに useForm が zod の文言を表示します。noValidate はマークアップに書かれないので、スクリプトが読み込まれるまではブラウザの検証が残ります。
  • そのため JavaScript が動いているときの送信は、ブラウザ側のメッセージにかかわらず Server Action に届きます。HTML で表せない検証(dropped に載ったもの)はサーバーでしか走らないので、決めるのはサーバーです。

触って確かめる

下のフォームは、このページの Server Component で formFields(defineForm(…)) が導いたデータを useForm に渡しています。ハンドルは 3 文字以上 20 文字以下で英小文字・数字・_ だけ、パスワードは 8 文字以上、確認欄には sameAs のルールが付いています。

  • ハンドルに 2 文字だけ入れて欄を離れると、メッセージが出ます。
  • 戻って入力を続けると、表示中のメッセージが値に合わせて変わり、条件を満たした時点で消えます。まだメッセージの無い欄に、入力の途中で新しく出ることはありません。
  • 確認欄にパスワードと違う値を入れて離れると、sameAs の文言が出ます。パスワードの側を直すと、確認欄に触らなくても消えます。
  • 「リセット」を押すと、値と一緒にメッセージが消え、isDirtyfalse に戻ります。

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 の答え)が最優先で、その後は ValidityFlagvalueMissingtypeMismatchpatternMismatchtooShorttooLongrangeUnderflowrangeOverflowstepMismatchbadInput の順です。
  • ルールの対象の欄は、どの欄のイベントでも読み直されます。別の欄を直して解消した違反は、その場で消えます。ただし、まだメッセージの出ていない欄に、この経路で新しく出すことはありません。
  • Server Action が返した state.errors のエラーは、その欄が編集されるまで表示されます。ブラウザ側のメッセージがあれば、そちらが優先されます。
  • Server Action から新しい結果が届くと、ブラウザ側のメッセージと「編集済み」の記録を捨て、行数を state.rows から作り直し、state.errors の先頭の欄にフォーカスを移します。スクリーンリーダーの利用者が、送信が失敗したことと、その場所を知るためです。
  • 結果は内容と token で比べます。同じ失敗が 2 回続いても token が違うので新しい答えとして扱い、編集して消えていたエラーをもう一度表示します。parseForm を通さずに FormState を自分で組み立てるときは、新しい token も入れます。入れなければ、同じ内容の 2 回目の失敗は同じ答えとして読まれます。

リセット

form.propsonReset はフォームのリセットを受け取ります。リセットボタン、form.reset()、action が終わったあと(何を返したかにかかわらず)React 自身が行うリセットのどれでも、古い値について覚えていたことを捨てます。

  • ブラウザ側のメッセージと、ルールが setCustomValidity で付けた文言を消します。
  • 「編集済み」の記録を消すので、今の state.errors にあるサーバーのエラーはもう一度表示されます。
  • 行数を、今の state.rows(無ければ .min())に戻します。
  • isDirty は、ブラウザが値を戻し終えたあとに DOM から読み直します。

React は action のあと、失敗を返したときにもフォームをリセットします。それでも入力が残るのは、state.valuesdefaultValue として描かれ、リセットがその値に戻すからです。パスワードは返されないので空になります。

変更の有無: isDirty

form.isDirty は、どれかの欄が描画されたときの値と違えば true です。値を追いかけるのではなく DOM から読み直すので、この値が再描画を起こすのは真偽値が変わるときだけです。比べるものは次のとおりです。

  • テキスト欄と <textarea>valuedefaultValue
  • チェックボックスとラジオボタンは checkeddefaultChecked
  • <select> は各 <option>selecteddefaultSelected
  • HiddenValue はマウントしたときの値
  • 繰り返し行は行数

リセットは HiddenValue を戻しません。値は呼び出し側の state で、React がそのまま書き戻すからです。編集された HiddenValue を持つフォームは、その state を戻すまで isDirty のままです。

サーバーが描く HTML では isDirty は常に false です。送信ボタンを disabled={!form.isDirty} にすると、JavaScript の無い人は送信できなくなります。表示や、ページを離れるときの確認に使います。

parseForm の結果

parseForm(schema, formData) は FormData をスキーマの形に組み立て直してから検証します。組み立てるのは構造だけ(ネスト、繰り返しの名前、チェックされていないボックス)で、'42'42 にするような型の変換はスキーマの z.coerce に任せます。

  • parseFormParseResult を返します。成功すれば { 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?string1 回の検証の識別子。同じ内容の結果が 2 回返っても、クライアントが区別できるようにするためのものです。

parseForm は、スキーマにある欄の名前が FormData に 1 つも無いと例外を投げます。input を広げ忘れたか、その欄を描いていないということで、入力した人の誤りではなく結線の誤りだからです。ただし、触られないと何も送らない入力要素は対象外です。何も選ばれていないラジオボタンは、プレースホルダーのままの <select> と同じく値が無いものとしてスキーマに渡り(undefined を受け付けない enum なら検証エラー)、チェックされていないチェックボックスは false、何もチェックされていないチェックボックス群は [] として届くので、これらで広げ忘れても例外にはなりません。

触られなかった入力要素がスキーマに何を渡すか(テキストは ''、チェックボックスは false、数値・z.coerce.bigint()・ファイル・選択肢は何も渡さない)は、フィールドのページの「required の決まり方」にまとめています。

サーバーにしか分からない検証

データベースに問い合わせないと分からない検証(ハンドルがすでに使われているかどうかなど)は、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 を返し、sameAsminCheckedrequiredWhen はそれぞれただのデータである 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 にも一致し、文言はほかのメッセージと同じ経路で出ます。
  • ルールは送られる文字列を比べます。sameAsrequiredWhen はその名前の最初の値を読み、何も送られていなければ '' として扱います。チェックされたチェックボックスは value(既定は on)を送ります。
  • ルールはサーバーでも評価されるので、スキーマ側に .min(2) を重ねて書く必要はありません。例の topics.min(2) を持っていません。
  • ルールの欄名はスキーマのパスで型付けされます。繰り返し行の中の欄はそのパスに含まれないので、ルールの対象にはできません。
  • これ以外の検証は .refine() のまま、サーバーでだけ走ります。ルートのオブジェクトの .refine()dropped に件数が載り、その問題は path を付ければその欄のエラーに、付けなければ state.formError になります。

サーバーに問い合わせる検証: useAsyncCheck

ハンドルが使われているかどうかは、サーバーにしか分かりません。useAsyncCheck(check) は欄を離れたときに check を呼び、答えを setCustomValidity で欄に付けます。答えはほかのメッセージと同じ経路で表示されます。

useAsyncCheckAsyncCheck を返します。check は値を受け取り、問題があれば文言を、無ければ undefined を返す非同期関数で、Server Action をそのまま渡せます。戻り値の propsonBlurref)を入力要素に広げます。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 でも同じ確認を行います。