Client API
The hooks, components and types that @k8ordo/form provides. Use them in Client Components; the server-side functions are under “Server API”.
On this page
useForm
Import from @k8ordo/form
Wires the fields formFields derived to a <form> and its controls.
useForm(fields: FormFields, state?: FormState): UseFormReturnParameters
fieldsFormFields- What
formFieldsreturned in a Server Component, passed down as props as it is. stateFormState- The
FormStatethe Server Action returned: the first value ofuseActionState. Leave it out for a GET form with no action.
Returns
UseFormReturn — The props to spread onto the <form>, and functions that return what each field shows.
Fields
props{ onSubmit, onBlur, onInput, onReset, ref }- The
onSubmit,onBlur,onInput,onResetandrefto spread onto the<form>, and nowhere else. field(path) => FieldView- Returns the
FieldViewfor a path. A nested field is joined with dots, as inuser.email. array(path) => ArrayView- Returns the
ArrayViewfor the path of an array of repeated rows. formErrorFormErrorView- The error no field owns, and the props for the element that shows it.
isDirtybooleantrueonce any control differs from the value it was rendered with. Adding or removing a row counts too.
Caveats
- Values stay in the DOM and are never copied into React state, so typing never re-renders.
- An
onSubmitof your own written after the spread replacesprops.onSubmit. Callform.props.onSubmit(event)from yours. noValidateis set from JavaScript and never rendered, so the browser keeps checking input while the page is still loading.- When a new
statearrives, focus moves to the first failure on the page. States are told apart by content andtoken, so a state built by hand needs a freshtoken.
talk-form.tsxconst [state, formAction] = useActionState(createTalk, {});
const form = useForm(fields, state);
const title = form.field('title');
<form {...form.props} action={formAction}>
<input {...title.input} />
{title.error !== undefined && <p>{title.error}</p>}
</form>See “Get started” for the whole flow, and “Show errors” for displaying them.
FieldView
Import from @k8ordo/form
What form.field() returns for one field.
type FieldView = {
input: FieldInput;
error: string | undefined;
invalid: boolean;
required: boolean;
};Fields
inputFieldInput- The attributes to spread onto the control:
name,type,required,maxLengthand so on. After a failed submission it carries the submitted value asdefaultValue. errorstring | undefined- The error to show now, from the browser’s check or from the server;
undefinedwhen there is none. invalidbooleantruewhile there is anerror. Pass it toaria-invalidand the like.requiredbooleantruewhen the schema rejects an empty submission. Use it for the label’s marker.
Caveats
- A server error stays until the field is edited, and an error from the browser’s check takes precedence over it.
- Only a
z.stringbool()field’sinputcarriesvalue, the string a checked box submits.
ArrayView
Import from @k8ordo/form
What form.array() returns for repeated rows.
type ArrayView = {
rows: RowView[];
add: () => void;
canAdd: boolean;
canRemove: boolean;
error: string | undefined;
errorProps: { id: string; tabIndex: -1 };
};Fields
rowsRowView[]- The rows on screen, one
RowVieweach. add() => void- Adds a row at the end.
canAddbooleantruewhile the schema’s.max()has not been reached.canRemovebooleantruewhile there are more rows than the schema’s.min().errorstring | undefined- The error about the array itself, too many or too few rows, which no row’s field carries.
errorProps{ id: string; tabIndex: -1 }- The
idandtabIndex={-1}to spread onto the element that showserror, so focus can land there after a failed submission.
order-form.tsxconst items = form.array('items');
{items.rows.map((row) => (
<div key={row.key}>
<input {...row.field('name').input} />
{items.canRemove && (
<button onClick={row.remove} type="button">
Remove
</button>
)}
</div>
))}
{items.canAdd && (
<button onClick={items.add} type="button">
Add a row
</button>
)}RowView
Import from @k8ordo/form
One repeated row.
type RowView = {
key: string;
index: number;
field: (itemKey?: string) => FieldView;
remove: () => void;
};Fields
keystring- The row’s identity, for
key. Removing a row leaves the others’ keys alone. indexnumber- The row’s current position, counted from 0.
field(itemKey?: string) => FieldView- Returns the
FieldViewfor a key within the row, named likeitems[0].name. For an array of scalars, call it with no argument. remove() => void- Removes this row.
Caveats
- Unlike paths, a key within a row is not checked by the types. A typo throws when the row renders.
FormErrorView
Import from @k8ordo/form
The shape of form.formError: an error no field owns, such as one from a .refine() on the whole schema.
type FormErrorView = {
message: string | undefined;
props: { id: string; tabIndex: -1 };
};Fields
messagestring | undefined- The text of
state.formError;undefinedwhen there is none. props{ id: string; tabIndex: -1 }- The
idandtabIndex={-1}to spread onto the element that shows the message.
useAsyncCheck
Import from @k8ordo/form
Asks the server about one field’s value when the person leaves it — for checks only the server can answer, such as whether a name is taken.
useAsyncCheck(
check: (value: string) => Promise<string | undefined>,
): AsyncCheckParameters
check(value: string) => Promise<string | undefined>- A function that takes the value and resolves to the message to show, or
undefinedwhen the value is fine. A Server Action fits.
Returns
AsyncCheck — The props to spread onto the control, and whether a check is in flight.
Fields
props{ onBlur, ref }- The
onBlurandrefto spread onto the control, next tofield().input. isCheckingbooleantruewhile an answer is outstanding. Use it to disable the submit button.
Caveats
- The answer is applied with
setCustomValidity, so it arrives in the sameerroras every other message. - When answers arrive out of order, only the newest is used. Leaving the field with the value the last answer was about does not ask again.
- Emptying the field and leaving it clears the last answer. A rejected promise applies nothing, and the server still checks the submission.
slug-field.tsxconst slug = form.field('slug');
const taken = useAsyncCheck(checkSlugAvailable);
<input {...slug.input} {...taken.props} />
<button disabled={taken.isChecking} type="submit">Save</button>HiddenValue
Import from @k8ordo/form
Carries the value of a component that renders no <input name> — a rich text editor, say — into the form’s submission.
<HiddenValue name={string} value={string} />Parameters
namestring- The name it submits under: the field’s path in the schema.
valuestring- The value to submit: the component’s state, as it is.
Caveats
- It fires an
inputevent on every change, so cross-field rules andisDirtynotice it like a keystroke. - A reset does not restore it: the value is the caller’s state, so reset that too.
post-form.tsx<Editor onChange={setBody} value={body} />
<HiddenValue name="body" value={body} />FormFields
Import from @k8ordo/form
What formFields returns and useForm takes. It is JSON, so it crosses from a Server Component as props.
type FormFields<FieldPath, ArrayPath, StringCheckboxPath> = {
fields: Record<FieldPath, DerivedField>;
arrays: Record<ArrayPath, DerivedArray>;
rules: DerivedRule[];
dropped: DroppedCheck[];
};Fields
fieldsRecord<FieldPath, DerivedField>- Each field by path, with the control’s attributes and a message per check.
arraysRecord<ArrayPath, DerivedArray>- Each array of rows by path, with its bounds and the fields of one row.
rulesDerivedRule[]- The cross-field rules from
defineForm, already worded when the fields were derived. droppedDroppedCheck[]- Checks no HTML attribute can express, which the browser does not run. The server still does.
Caveats
- Its type arguments are the field paths, the array paths, and the
z.stringbool()field paths. They are inferred from whatformFieldsreturns, so you rarely write them. - It can be imported from either
@k8ordo/formor@k8ordo/form/server.