0.1.0-alpha.7

Forms

A form in zerolith is a native <form> with a method and an action, and @zerolith/forms submits it with fetch so the page stays where it is. Keeping the form native pays twice: the browser's own behavior keeps working, from Enter submitting to a button's formaction and the autofill a real form gets, and a page the server renders has a form that works before its script loads. Validation is one schema that runs in the browser and on the server, and a failure travels as a problem details document whose fields mark the inputs.

The form

createForm returns the enhancer for one form. Bind its submit to the form's @submit, and bind the field signals to the inputs that show their messages:

import { html, render } from "zerolith";
import { createForm } from "@zerolith/forms";
import { noteSchema } from "./note-schema.ts";

const noteForm = createForm({
    schema: noteSchema,
    reset: true,
    onSuccess: ({ result }) => console.log("created", result)
});

render(html`
    <form method="post" action="/notes" @submit=${noteForm.submit}>
        <label>Title
            <input name="title" aria-describedby="title-error"
                aria-invalid=${noteForm.field("title").invalid}>
        </label>
        <output id="title-error">${noteForm.field("title").error}</output>
        <button ?disabled=${noteForm.pending}>Save</button>
    </form>`, "#app");
Binding What it does
@submit=${noteForm.submit} Sends the submission with fetch instead of loading a new page
aria-invalid=${noteForm.field("title").invalid} Marks the input while it has a message
${noteForm.field("title").error} Shows the field's first message
?disabled=${noteForm.pending} Disables the button while the submission runs

The method, action, and encoding stay on the element, where the browser reads them too. aria-describedby points the input at its message, so a screen reader reads the message with the field.

The enhancer keeps the state of the current submission in signals:

Member Holds
status "idle", then "pending" during a submission, then "success", "failure", or "error"
pending true while a submission runs
problem The problem from the last failed submission
result The parsed body of the last successful submission
error What the last submission that could not complete threw
field(name) One input's view of the problem: errors, every message for it; error, the first; invalid, true while it has one. The same object for the same name
submit(event) The submit handler
reset() Aborts a submission in flight and returns to idle

A new submission clears the last problem as it starts, so old messages disappear while it runs.

Validation, written once

Validation is a Standard Schema, the interface zod, valibot, arktype, and other schema libraries implement. The schema below is written by hand to show the interface; a zod schema such as z.object({ title: z.string().min(2) }) plugs in the same way.

import type { StandardSchema } from "@zerolith/forms";

export interface Note
{
    title: string;
}

// Any Standard Schema library works; this one is written by hand to show
// the interface.
export const noteSchema: StandardSchema<unknown, Note> = {
    "~standard": {
        version: 1,
        vendor: "notes",
        validate(value)
        {
            const title = typeof value === "object" && value !== null
                ? (value as { title?: unknown }).title
                : undefined;
            if (typeof title === "string" && title.trim().length >= 2)
            {
                return { value: { title: title.trim() } };
            }
            const message = "Give the note a title of at least two characters.";
            return { issues: [{ message, path: ["title"] }] };
        }
    }
};

Given to createForm, the schema runs in the browser before anything is sent: a failing submission becomes a problem whose fields come from the issues' paths, and no request is made. validateForm runs the same schema on the server, or anywhere FormData or a plain object is at hand, so both sides apply exactly the same rules.

The server

Any server that can read FormData and write a Response fits. validateForm turns a submission into the parsed value or a problem, and problemResponse writes the problem with the application/problem+json media type:

import { problemResponse, validateForm } from "@zerolith/forms";
import { type Note, noteSchema } from "./note-schema.ts";

const notes: (Note & { id: number })[] = [];

async function createNote(request: Request): Promise<Response>
{
    const validation = await validateForm(noteSchema, await request.formData());
    if (validation.problem !== undefined)
    {
        return problemResponse(validation.problem);
    }
    const note = { id: notes.length + 1, ...validation.value };
    notes.push(note);
    return Response.json(note, { status: 201 });
}

A title that is too short gets this document, with status 400:

{
    "type": "about:blank",
    "title": "The submission has invalid fields",
    "status": 400,
    "fields": { "title": ["Give the note a title of at least two characters."] }
}

It is the same document the enhancer builds when the schema fails in the browser, so the form shows a message the same way whichever side found it. fields is the extension to RFC 9457 that zerolith defines: a map from field name to its messages, where a nested path is joined with dots, as address.city, and an issue with no path goes to detail. The Problem type and isProblem live in the core, so the typed client and the forms share them, and a server written in another language answers with the same JSON and needs no schema on the client at all.

What the enhancer does with the response

Response Outcome
application/problem+json, or any failing status failure; problem holds the document, or a problem built from the status, and field() follows its fields
A redirect was followed success, then navigate(url) with the final URL; result stays undefined
2xx with a JSON body success; result holds the parsed body
2xx with another body, or none success; result holds the text, or undefined
The request itself failed error; error holds the exception

Options

Option What it does
schema Validates in the browser before anything is sent
send Replaces the request: it receives the submission, with its data, method, action, and abort signal, and returns a promise of the Response
headers Adds headers to the default request
fetch The fetch the default request uses, the global one unless given
navigate Where a redirected response takes the page, location.assign unless given; pass a function that calls the router's navigate to stay in the application
reset Resets the form element after a successful response
onSuccess Runs after a successful response, with its result; an error it throws is reported as an uncaught error is, with reportError, and leaves status as it is
onFailure Runs with the problem, from the schema or the server; an error it throws is reported the same way
onError Runs when the request itself fails

The request

The default request copies what the browser would send:

  • The method and action come from the form, after the submitter's formmethod and formaction, read as the browser reads them: a method it does not know, such as put, submits as get, as a native form would, so a PUT or DELETE needs a send of its own. The submitter's name and value are part of the data in every supported browser.
  • The body follows the encoding, enctype or the submitter's formenctype: URL-encoded by default, FormData for multipart/form-data, and lines of text for text/plain. A GET puts the data in the query string instead.
  • The Accept header asks for JSON or a problem, unless headers sets one, so a server can tell an enhanced submission from a native one.
  • Cookies go with a request to the same origin.

A form submitted again aborts the request still in flight and discards its outcome. A form with method="dialog" is left to the browser.

Serving the form without JavaScript

When a server renders the page, the form works before its script loads, and keeps working if the script fails: the browser posts the same data to the same action and shows whatever the server answers. The server tells the two kinds of request apart by their Accept header:

Request Failure Success
From @zerolith/forms, accepting JSON problemResponse(problem), which marks the fields JSON the page reads from result, or a redirect the enhancer follows
A native submission, accepting HTML The page rendered again with the messages A 303 See Other redirect to the new resource

A redirect suits both: the browser follows it natively, and the enhancer follows it with fetch, then navigates to where it ended. In a client-rendered application the form exists only once the script runs, so the enhanced path is the only one, and the native form still pays off in the browser's own behavior. The server rendering guide covers rendering the page on the server.