@k8ordo/form

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.

ts
useForm(fields: FormFields, state?: FormState): UseFormReturn

Parameters

fieldsFormFields
What formFields returned in a Server Component, passed down as props as it is.
stateFormState
The FormState the Server Action returned: the first value of useActionState. 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, onReset and ref to spread onto the <form>, and nowhere else.
field(path) => FieldView
Returns the FieldView for a path. A nested field is joined with dots, as in user.email.
array(path) => ArrayView
Returns the ArrayView for the path of an array of repeated rows.
formErrorFormErrorView
The error no field owns, and the props for the element that shows it.
isDirtyboolean
true once 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 onSubmit of your own written after the spread replaces props.onSubmit. Call form.props.onSubmit(event) from yours.
  • noValidate is set from JavaScript and never rendered, so the browser keeps checking input while the page is still loading.
  • When a new state arrives, focus moves to the first failure on the page. States are told apart by content and token, so a state built by hand needs a fresh token.
talk-form.tsx
const [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.

ts
type FieldView = {
  input: FieldInput;
  error: string | undefined;
  invalid: boolean;
  required: boolean;
};

Fields

inputFieldInput
The attributes to spread onto the control: name, type, required, maxLength and so on. After a failed submission it carries the submitted value as defaultValue.
errorstring | undefined
The error to show now, from the browser’s check or from the server; undefined when there is none.
invalidboolean
true while there is an error. Pass it to aria-invalid and the like.
requiredboolean
true when 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’s input carries value, the string a checked box submits.

ArrayView

Import from @k8ordo/form

What form.array() returns for repeated rows.

ts
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 RowView each.
add() => void
Adds a row at the end.
canAddboolean
true while the schema’s .max() has not been reached.
canRemoveboolean
true while 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 id and tabIndex={-1} to spread onto the element that shows error, so focus can land there after a failed submission.
order-form.tsx
const 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.

ts
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 FieldView for a key within the row, named like items[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.

ts
type FormErrorView = {
  message: string | undefined;
  props: { id: string; tabIndex: -1 };
};

Fields

messagestring | undefined
The text of state.formError; undefined when there is none.
props{ id: string; tabIndex: -1 }
The id and tabIndex={-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.

ts
useAsyncCheck(
  check: (value: string) => Promise<string | undefined>,
): AsyncCheck

Parameters

check(value: string) => Promise<string | undefined>
A function that takes the value and resolves to the message to show, or undefined when 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 onBlur and ref to spread onto the control, next to field().input.
isCheckingboolean
true while an answer is outstanding. Use it to disable the submit button.

Caveats

  • The answer is applied with setCustomValidity, so it arrives in the same error as 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.tsx
const 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.

ts
<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 input event on every change, so cross-field rules and isDirty notice 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.

ts
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 what formFields returns, so you rarely write them.
  • It can be imported from either @k8ordo/form or @k8ordo/form/server.
k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2