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. useFormshows a message when a field is left, so an untouched field shows nothing even thoughcheckValidity()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,
useFormmoves focus to the first field instate.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>
);
}TextFieldaccepts onlytext,email,tel,urlandsearchas itstype, whileinput.typeis astring, so spreading it as it is fails to type-check. Taketypeout, spread the rest, and writetypeyourself.PasswordInputsetstypeitself to show and hide the password. Spreading the derivedtypebreaks the toggle, and the compiler does not catch it, so always take it out. A<textarea>has notypeattribute either, so take it out forTextareatoo.- The
inputderived fromz.enum()has notype, so it spreads ontoSelectas it is. The choices go inoptions. NumberFieldkeeps its value in React state, renders atype="text"input, and submits0even when untouched. Neither the rule that a blank numeric field means nothing entered nor the derivedrequiredcan 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.
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 onemultipleinput, andparseFormexpects 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 indropped; 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 indropped, and fails on the server. - Reporting a
.refine()/.superRefine()on a single field, on a nested object, or on a row indropped. 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.valueon input works with the DOM as the source of truth, but managing the caret is a separate problem from wiring a form.