@k8ordo/form

Get Started

Write a form's constraints once, in a zod schema. @k8ordo/form derives from it the constraint attributes and messages the browser gets, and the typed validation the Server Action runs. The client-side checks are derived, never written by hand.

The idea

Form validation is usually written twice: required and maxLength in the JSX, and a validation routine on the server. The two are edited separately, so sooner or later they disagree. This package keeps one place to write it and derives the rest.

  • The schema is the only source. Attributes such as required, minLength and type="email", the message shown next to a field, and the validation on the server all come from it.
  • The DOM holds the values. React state carries only the messages on screen, the server errors that are still current, the identity of each repeated row, one dirty flag, and the row counts that adding or removing a row is measured against. Values are never copied into state, so the form does not re-render on every keystroke.
  • It works without JavaScript. The constraint attributes are in the HTML the server sends, so the browser validates on its own with scripts disabled or not yet loaded.
  • The server decides. Some checks have no HTML attribute, so parseForm validates every submission against the same schema and answers with per-field errors and the submitted values.

Installation

Add @k8ordo/form and zod, which the schema is written in.

npm install @k8ordo/form zod

The peer dependencies are below. TypeScript and @types/react are needed only for the shipped type declarations.

PackageVersionNeeded for
react>=19.3.0useForm and Server Actions
react-dom>=19.3.0Rendering
zod^4.4.3The schema (zod/mini works too)
typescript>=7.0.2The shipped type declarations (optional)
@types/react>=19.3.0The shipped type declarations (optional)

zod or zod/mini

The conversion reads zod's shared core, so a schema written with either entry works, and nothing in the package notices which one you chose. Only the Server Component and the Server Action import the schema, so zod does not reach the browser either way. When the client imports the schema module too — a GET form with @k8ordo/state, for instance — zod/mini keeps that bundle small.

import * as z from 'zod';

export const talkSchema = z.object({
  title: z
    .string()
    .min(1, 'Enter a title')
    .max(120, 'Use 120 characters or fewer'),
});
import * as z from 'zod/mini';

export const talkSchema = z.object({
  title: z
    .string()
    .check(
      z.minLength(1, 'Enter a title'),
      z.maxLength(120, 'Use 120 characters or fewer'),
    ),
});

zod/mini bundles no locale, so its default message is Invalid input — unless the classic zod entry is also in use in the same process, which installs its English locale globally. The text next to a field is zod's own too, so either write the messages yourself or load a locale, as in z.config(z.locales.ja()).

The server/client split

There are two entry points. @k8ordo/form/server is the side that reads the schema; @k8ordo/form holds the hooks that run in the browser. A form is put together in three steps.

  1. Call formFields(schema) in a Server Component or at its module scope. The result is plain JSON — attributes, messages, rules and the dropped report — with no functions and no zod.
  2. Pass the result to a client component as a prop and hand it to useForm(fields, state). Being JSON, it crosses the RSC boundary, and zod stays out of the client bundle.
  3. The Server Action receives the submission, and parseForm(schema, formData) returns either typed data or a state holding per-field errors.
EntryValuesTypes
@k8ordo/form/serverformFields, parseForm, defineForm, sameAs, minChecked, requiredWhenParseResult, FormDefinition
@k8ordo/formuseForm, useAsyncCheck, HiddenValueUseFormReturn, FieldView, ArrayView, RowView, AsyncCheck
BothFormFields, FormState, DerivedField, DerivedArray, DroppedCheck, FieldInput, ValidityFlag, Rule

The types of the formFields result and of the action state are exported from both entries, since the result shows up on the client as a prop.

A complete form

A form that registers a talk, in four files: the schema, the page, the Server Action and the form. The example is an @k8ordo/server application, which has Server Actions.

1. The schema

Type conversion belongs in the schema. Every submitted value is a string, so a number is read with z.coerce.number(). An empty numeric field arrives as nothing entered, not as 0, so the wording for an untouched field is the argument to z.coerce.number(). Depending on the zod version, that wording also stands in for any check without its own (4.5.4 does this, 4.4.3 does not), so give .int() and .min() theirs.

// src/routes/talks/new/_parts/talk-schema.ts
import * as z from 'zod';

export const talkSchema = z.object({
  title: z
    .string()
    .min(1, 'Enter a title')
    .max(120, 'Use 120 characters or fewer'),
  eventUrl: z.url('Enter the event URL'),
  minutes: z.coerce
    .number('Enter the length in minutes')
    .int('Use whole minutes')
    .min(5, 'A talk is at least 5 minutes'),
  recorded: z.boolean(),
});

2. The page (Server Component)

formFields runs once, at module scope. The result is the same for every request, so there is no reason to derive it on each render. Call it inside the render only when the messages follow the request, such as its language.

// src/routes/talks/new/page.tsx
import { formFields } from '@k8ordo/form/server';

import { TalkForm } from './_parts/talk-form';
import { talkSchema } from './_parts/talk-schema';

const talkFields = formFields(talkSchema);

export default function NewTalkPage() {
  return <TalkForm fields={talkFields} />;
}

3. The Server Action

When parseForm fails, return parsed.state as it is. It holds the per-field errors and the submitted values (never passwords or files), so a retry keeps what was typed, with or without JavaScript. On success, parsed.data has the schema's output type.

// src/routes/talks/new/_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 { talkSchema } from './talk-schema';
import { insertTalk } from './talks.server';

export async function createTalk(
  _previous: FormState,
  formData: FormData,
): Promise<FormState> {
  const parsed = parseForm(talkSchema, formData);
  if (!parsed.success) return parsed.state;
  await insertTalk(parsed.data);
  redirect('/talks');
}

Server Actions in @k8ordo/server

4. The form (client component)

useForm returns a UseFormReturn: props, field, array and isDirty. Spread form.props onto the <form>; there is no per-field registration. Spread the input that field(path) returns onto the control, and show error when there is one. Paths are typed from the schema, so a typo stops at compile time. In the props type, FormFields<FieldPath, ArrayPath> takes the field() paths first and the array() paths second (never when there are none).

// src/routes/talks/new/_parts/talk-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { createTalk } from './actions';

type Props = {
  fields: FormFields<'title' | 'eventUrl' | 'minutes' | 'recorded', never>;
};

export function TalkForm({ fields }: Props) {
  const [state, formAction] = useActionState(createTalk, {});
  const form = useForm(fields, state);
  const title = form.field('title');
  const eventUrl = form.field('eventUrl');
  const minutes = form.field('minutes');
  const recorded = form.field('recorded');

  return (
    <form {...form.props} action={formAction}>
      <label>
        Title
        <input {...title.input} aria-invalid={title.invalid} />
      </label>
      {title.error !== undefined && <p>{title.error}</p>}

      <label>
        Event URL
        <input {...eventUrl.input} aria-invalid={eventUrl.invalid} />
      </label>
      {eventUrl.error !== undefined && <p>{eventUrl.error}</p>}

      <label>
        Minutes
        <input {...minutes.input} aria-invalid={minutes.invalid} />
      </label>
      {minutes.error !== undefined && <p>{minutes.error}</p>}

      <label>
        <input {...recorded.input} />
        Recorded
      </label>

      {state.formError !== undefined && <p>{state.formError}</p>}
      <button type="submit">Register</button>
    </form>
  );
}

form.props is ref, onBlur, onInput and onReset. Writing a prop of the same name on the <form> yourself leaves only the one written last in effect, which can disconnect useForm's validation or reset handling.

To avoid listing the paths in the props type by hand, bring in the schema and formFields with import type and write ReturnType<typeof formFields<typeof talkSchema>>. A type-only import puts neither zod nor the schema into the client bundle.

// src/routes/talks/new/_parts/talk-form.tsx
'use client';

import type { formFields } from '@k8ordo/form/server';

import type { talkSchema } from './talk-schema';

type Props = {
  fields: ReturnType<typeof formFields<typeof talkSchema>>;
};

What happens on submit

  • Without JavaScript, the browser validates against the constraint attributes and stops a submission that fails, in its own wording.
  • With JavaScript, useForm sets noValidate on the form and shows zod's wording when a field is left. It does not block the submission; the submission goes to the Server Action.
  • When parseForm reports a failure, each error appears next to its field, and focus moves to the first field listed in state.errors.

How a message appears and goes away, and what parseForm returns in detail, are on the Validation page.

Next steps