@k8ordo/form

Patterns

Common ways to put the package to work: multi-step forms, GET forms with @k8ordo/state, pairing with @k8ordo/ui components, and what the package does not do yet.

Multi-step forms

Keep every step rendered and hide the ones you are not on. The values stay in the DOM, so moving between steps loses nothing, and the form submits once, at the end.

  • Hide steps only once hydration is done. Hidden in the HTML the server sends, the later steps would be out of reach without JavaScript. Left visible, the form without JavaScript is one long form that submits in a single request — which is the correct behaviour, not a broken one.
  • Before advancing, check only the controls inside the current step with checkValidity(). The later steps are not filled in yet, so checking the whole form would always fail.
  • useForm shows a message when a field is left, so an untouched field shows nothing even though checkValidity() fails. Move focus to the first invalid field, and its message appears when the person leaves it.
  • Once hydrated, render the submit button only on the last step. Pressing Enter in a text field can make the browser click the form's first submit button, even a hidden one, which would send the whole form from an earlier step and skip the step check.
  • After a failed submission, useForm moves focus to the first field in state.errors, but a field in a hidden step cannot take focus. When a new result arrives, switch during render to the step that holds the first error.
// 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>
  );
}

GET forms with @k8ordo/state

A search or filter form is a GET form. Hand the url schema of an @k8ordo/state definePageState to formFields, and the same schema becomes the source of both the form's constraints and the URL state.

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

This schema is used by useAppState in the browser, so it ships in the client bundle. This is the case zod/mini is for.

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

No Server Action receives it, so call useForm(fields) without a state. Submitting lands the values in the URL's search params, and useAppState reads them back through the same schema. Pass the current state to each control as its 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>
  );
}

Under @k8ordo/router, the router intercepts a GET form submitted to the same pathname and treats it as a state update, not a page load. Without JavaScript, the same form lands on the same URL.

Working with @k8ordo/ui

@k8ordo/form does not depend on @k8ordo/ui. The attributes go to the input and the error to FormControl, which generates the id and the aria-* links itself, so it needs only label, errorText, invalid and 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 accepts only text, email, tel, url and search as its type, while input.type is a string, so spreading it as it is fails to type-check. Take type out, spread the rest, and write type yourself.
  • PasswordInput sets type itself to show and hide the password. Spreading the derived type breaks the toggle, and the compiler does not catch it, so always take it out. A <textarea> has no type attribute either, so take it out for Textarea too.
  • The input derived from z.enum() has no type, so it spreads onto Select as it is. The choices go in options.
  • NumberField keeps its value in React state, renders a type="text" input, and submits 0 even when untouched. Neither the rule that a blank numeric field means nothing entered nor the derived required can apply, so for a number, spread the attributes onto a plain <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}
    />
  );
}

This section covers only TextField, PasswordInput, Textarea, Select and NumberField. Render checkboxes, radio groups and files with the plain elements shown on the Fields page; Radio, for one, does not pass required on to its inputs.

Checkboxes, radio groups and files on the Fields page

What it does not do yet

The current version does not do the following.

  • Several files in one control. z.array(z.file()) derives repeated single-file rows rather than one multiple input, and parseForm expects one file per name.
  • Constraints behind a .transform(), or a .pipe() whose output JSON Schema cannot describe. The schema no longer says what the control submits, so the field derives as a bare text input and is listed in dropped; every check still runs on the server.
  • Refusing, at derive time, a z.custom() that rejects the strings a text control submits. Nothing about it is readable, so it derives as a text input, is listed in dropped, and fails on the server.
  • Reporting a .refine() / .superRefine() on a single field, on a nested object, or on a row in dropped. Only the count of the root object's checks is reported; the others run on the server without a word on the client.
  • Rules that target fields inside repeated rows. Rule field names are typed against the schema's paths, and row fields are not among them.
  • Input masking. Rewriting el.value on input works with the DOM as the source of truth, but managing the caret is a separate problem from wiring a form.