@k8ordo/form

Validation

Validation runs in two places from one schema: the browser checks the constraint attributes and shows zod's wording, and the Server Action makes the final call. This page covers what each side guarantees, how a message appears and goes away, what parseForm returns, cross-field rules, and checks that ask the server.

Two layers: the browser and the server

Both sides read the same schema, so a message useForm shows is zod's own wording for that check — the same text parseForm uses for it on the server. What differs is when each side checks, and how much: the browser runs only what became an attribute or a rule, and when a value fails several checks, the two sides may name a different one first.

  • Without JavaScript, the constraint attributes are simply the browser's constraint validation: it stops a failing submission, in its own wording.
  • Once JavaScript runs, the ref in form.props sets noValidate on the form. The browser stops blocking submission, and useForm shows zod's wording instead. noValidate is never rendered into the markup, so the browser's own validation stays on until the script loads.
  • So with JavaScript, a submission reaches the Server Action whatever the browser-side messages say. The checks HTML cannot express — the ones in dropped — run only on the server, which is why the server decides.

Try it

The form below hands useForm the data formFields(defineForm(…)) derived in this page's Server Component. The handle takes 3 to 20 lowercase letters, digits or _, the password at least 8 characters, and the confirmation carries a sameAs rule.

  • Type two characters into the handle and leave the field: the message appears.
  • Go back and keep typing: the message on screen follows the value and disappears once it is valid. A field with no message yet never gains one mid-word.
  • Enter a confirmation that differs from the password and leave it: the sameAs message appears. Fix the password instead, and it clears without touching the confirmation.
  • Press Reset: the messages go with the values, and isDirty returns to false.

isDirty: false

This site is built statically with @k8ordo/static and has no Server Action, so there is no submit button. In a real form, the server takes it from here.

Part of the data that crossed to the client. The wording was derived in this page's language.

{
  "fields": {
    "handle": {
      "input": {
        "name": "handle",
        "required": true,
        "type": "text",
        "minLength": 3,
        "maxLength": 20,
        "pattern": "^[a-z0-9_]+$"
      },
      "messages": {
        "valueMissing": "Use at least 3 characters",
        "tooShort": "Use at least 3 characters",
        "tooLong": "Use 20 characters or fewer",
        "patternMismatch": "Use lowercase letters, digits and _"
      },
      "secret": false
    }
  },
  "rules": [
    {
      "kind": "sameAs",
      "field": "confirm",
      "other": "password",
      "message": "The passwords do not match"
    }
  ]
}

The life of a message

useForm reads the browser's ValidityState and shows the schema's wording for the flag that fails. This is when a message appears and when it goes away.

  • When a field loses focus with an invalid value, its message appears.
  • While typing, only a message already on screen is refreshed, and it clears once the value is valid. A new message is never raised mid-word — the same idea as :user-invalid.
  • Which wording shows: a message set with setCustomValidity (a rule, or a useAsyncCheck answer) comes first, then the ValidityFlag values valueMissing, typeMismatch, patternMismatch, tooShort, tooLong, rangeUnderflow, rangeOverflow, stepMismatch and badInput, in that order.
  • A rule's field is re-read on every event, whichever field it came from, so a breach fixed from the other field clears on the spot. A field that shows no message yet does not gain one this way.
  • An error from the action's state.errors shows until its field is edited. A browser-side message, when there is one, takes precedence.
  • When a new result arrives from the action, the browser-side messages and the record of edited fields are dropped, rows are rebuilt from state.rows, and focus moves to the first field in state.errors — which is how someone using a screen reader learns that the submit failed, and where.
  • Results are compared by content plus token. Two identical failures carry different tokens, so the second is treated as a new answer and brings back the errors that editing had hidden. A FormState built by hand, without parseForm, needs a fresh token too; without one, a second identical failure reads as the same answer.

Reset

The onReset in form.props catches the form being reset — by a reset button, by form.reset(), or by React itself after a form action, whatever it returned — and forgets what it knew about the old values.

  • The browser-side messages go, and so do the messages rules set with setCustomValidity.
  • The record of edited fields goes, so the server errors in the current state.errors show again.
  • Rows go back to the current state.rows, or to .min() without one.
  • isDirty is read back from the DOM once the browser has restored the values.

React resets the form after the action even when it returned a failure. The input survives because state.values is rendered as defaultValue, which is what the reset restores; passwords are never echoed, so they come back empty.

Unsaved changes: isDirty

form.isDirty is true once any field differs from the value it was rendered with. It is read back from the DOM rather than tracked, so the flag causes a re-render only when it flips. What it compares:

  • Text inputs and <textarea>: value against defaultValue
  • Checkboxes and radio buttons: checked against defaultChecked
  • <select>: each option's selected against defaultSelected
  • HiddenValue: the value it mounted with
  • Repeated rows: the row count

A reset does not restore a HiddenValue: its value is the caller's state, which React writes straight back. A form holding an edited one stays dirty until that state is reset too.

In the HTML the server renders, isDirty is always false. A submit button with disabled={!form.isDirty} leaves a visitor without JavaScript unable to submit; use the flag for an indicator or a leave-page prompt instead.

What parseForm returns

parseForm(schema, formData) rebuilds the FormData into the schema's shape, then validates. It rebuilds only the structure — nesting, repeated names, unchecked boxes; converting '42' to 42 stays the schema's job, through z.coerce.

  • parseForm returns a ParseResult. On success: { success: true, data, state }, with data of the schema's output type.
  • On failure: { success: false, state }. Return that state from the action as it is.

Inside FormState

MemberTypeMeaning
errors?Record<string, string>Errors keyed by the field's name. Only the first issue per field is kept, and a rule breach on the same field takes its place (the rule declared first, as in the browser, when several break).
values?Record<string, string | string[]>The submitted values, so a retry keeps the input. Fields marked as passwords and files are never included. A checkbox group is an array however many boxes were checked, and any other name submitted more than once becomes an array too.
rows?Record<string, number>The row count per repeated group, so a retry renders the same rows, with or without JavaScript.
formError?stringAn issue that belongs to no field, such as a root .refine() without a path. useForm does not show it; render state.formError yourself.
token?stringThe identity of one parse, so the client can tell apart two results with identical content.

parseForm throws when a field in the schema never arrived in the FormData. That means an input was not spread or the field was not rendered — a wiring mistake, not something the person filling in the form did. The controls that submit no entry at all when left alone are exempt: a radio group with nothing selected reaches the schema as no value, like a <select> on its placeholder (a validation error unless the enum accepts undefined), an unchecked checkbox as false, and a checkbox group with nothing checked as [], so a forgotten spread on one of those is not caught.

What an untouched control hands the schema — '' for text, false for a checkbox, nothing at all for a number, a z.coerce.bigint(), a file or a choice — is laid out under “Where required comes from” on the Fields page.

Checks only the server can make

A check only the database can answer, such as whether a handle is already taken, runs after parseForm succeeds and answers in the same FormState shape. Spreading parsed.state carries the submitted values and the token along.

// 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;
}

Cross-field rules: defineForm

Password confirmation, “pick at least two”, “a company name is required only on the business plan” — none of these has a constraint attribute, and a .refine() is a function, so it cannot cross to the client. Declare them next to the schema as data instead.

// 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) returns a FormDefinition, and sameAs, minChecked and requiredWhen each return a Rule, which is plain data. Pass the definition wherever the schema went: formFields(signup) carries the rules to the client as data, and parseForm(signup, formData) evaluates them on the server. Both sides run the same evaluator, so there is no second implementation to drift from the first.

// 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');
}
RuleBreached when
sameAs(field, other, message)field's value differs from other's
minChecked(field, min, message)Fewer than min values are submitted under field
requiredWhen(field, when, equals, message)when equals equals and field is empty
  • In the browser, a breach is set on the field with setCustomValidity, so it is indistinguishable from a built-in check: :user-invalid matches, and the message arrives the same way as every other.
  • Rules compare the submitted strings. sameAs and requiredWhen read the first value under a name and treat nothing submitted as ''. A checked checkbox submits its value (on by default).
  • Rules are evaluated on the server too, so there is no need to repeat the bound as .min(2) in the schema; topics in the example has none.
  • Rule field names are typed against the schema's paths. Fields inside repeated rows are not among those paths, so a rule cannot target them.
  • Anything else stays a .refine() and runs on the server only. A .refine() on the root object is counted in dropped; its issue lands on a field when given a path, and in state.formError when not.

Asking the server about one field: useAsyncCheck

Whether a handle is taken is something only the server knows. useAsyncCheck(check) calls check when the field is left and sets the answer with setCustomValidity, so it shows up through the same path as every other message.

useAsyncCheck returns an AsyncCheck. check takes the value and resolves to a message when something is wrong or undefined when not; a Server Action can be passed as it is. Spread the returned props (onBlur and ref) onto the input. isChecking is true while an answer is outstanding.

// 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>
  );
}
  • When answers arrive out of order, only the newest question's answer counts, and an answer is discarded if the field no longer holds the value it was asked about.
  • Leaving the field while it still holds the value the last answer was about does not ask again; a blur while a check is still in flight does.
  • Emptying the field and leaving it clears the last answer.
  • If check throws, no verdict is applied either way.
  • An answer that lands after unmount is ignored.

useAsyncCheck only shows the answer early; parseForm never calls it. Make the same check in the Server Action as well, as in the example above.