Documentation menu

Forms

Gyral forms are real <form> elements, and a submit is an intent like any other. Start from the complete example below: one component with everything a validated form needs, built on the browser's own checks, with no extra packages. Copy it and change the fields; you don't need another page to make it work.

Complete example: a validated form

A signup form with a name, an email, a password and a checkbox the user must tick. On submit it shows every error next to its field, announces how many fields to fix, and moves focus to the first invalid one. Fixing a field clears its error; a valid submit announces a welcome.

// src/account-form.ts
import { define, focus, html } from '@gyral/core';

/** The fields, in the order they appear: the first one with an error gets focus. */
export const FIELDS = ['name', 'email', 'password', 'consent'] as const;
export type Field = (typeof FIELDS)[number];

/** One message per field; `''` means no error. */
export type Errors = Readonly<Record<Field, string>>;

export const NO_ERRORS: Errors = { name: '', email: '', password: '', consent: '' };

export interface State {
  readonly errors: Errors;
  /** What the live region announces after a rejected submit; `''` otherwise. */
  readonly summary: string;
  /** The clean values after a successful signup. */
  readonly account: { readonly name: string; readonly email: string } | undefined;
}

export type Msg =
  | { readonly _tag: 'Rejected'; readonly errors: Errors }
  | { readonly _tag: 'SignedUp'; readonly name: string; readonly email: string }
  | { readonly _tag: 'Checked'; readonly field: Field; readonly error: string };

const isField = (name: string): name is Field => FIELDS.some((f) => f === name);

/**
 * Your message for a field's first problem, from the browser's own checks (`validity`), or
 * `''` when the field is fine. The constraints in the markup (`required`, `type="email"`,
 * `minlength`) still decide what is wrong; `novalidate` only stops the browser from
 * blocking the submit and showing its own bubble.
 */
export function problemWith(input: HTMLInputElement): string {
  const { validity } = input;
  // A checkbox is missing when unchecked. `required` accepts "   ", so blank text is missing too.
  const missing =
    input.type === 'checkbox' ? !input.checked : validity.valueMissing || input.value.trim() === '';
  switch (input.name) {
    case 'name':
      return missing ? 'Enter your name.' : '';
    case 'email':
      if (missing) return 'Enter your email address.';
      return validity.typeMismatch ? 'Enter an email address like ada@example.com.' : '';
    case 'password':
      if (missing) return 'Enter a password.';
      // `tooShort` is set only for typed values; the length check also covers a pre-filled one.
      return validity.tooShort || input.value.length < input.minLength
        ? 'Use at least 8 characters.'
        : '';
    case 'consent':
      return missing ? 'Accept the privacy policy to continue.' : '';
    default:
      return '';
  }
}

/** What the status region announces after a successful signup. */
const welcome = (account: State['account']): string =>
  account === undefined ? '' : `Welcome, ${account.name}! We’ll write to ${account.email}.`;

/** Reads a submitted form: every field's error, or the clean values. */
function submission(form: Element, data: FormData | undefined): Msg | undefined {
  if (!(form instanceof HTMLFormElement) || data === undefined) return undefined;
  const errors: Record<Field, string> = { ...NO_ERRORS };
  for (const field of FIELDS) {
    const input = form.elements.namedItem(field);
    if (input instanceof HTMLInputElement) errors[field] = problemWith(input);
  }
  if (FIELDS.some((f) => errors[f] !== '')) return { _tag: 'Rejected', errors };
  const text = (field: Field): string => {
    const value = data.get(field);
    return typeof value === 'string' ? value.trim() : '';
  };
  // The password stays in the form data: never keep it in state.
  return { _tag: 'SignedUp', name: text('name'), email: text('email') };
}

export const AccountForm = define<State, Msg>()('my-account-form', {
  init: () => ({ errors: NO_ERRORS, summary: '', account: undefined }),
  intent: {
    // Not a message tag, so it may return either message.
    Submit: ({ target, formData }) => submission(target, formData),
    // Text fields send it on `input`, the checkbox on `change`.
    Checked: ({ target }) =>
      target instanceof HTMLInputElement && isField(target.name)
        ? { _tag: 'Checked', field: target.name, error: problemWith(target) }
        : undefined,
  },
  update: {
    // Show every error at once, announce how many, and focus the first field that has one.
    Rejected: (s, m) => {
      const count = FIELDS.filter((f) => m.errors[f] !== '').length;
      const first = FIELDS.find((f) => m.errors[f] !== '');
      const summary = `Fix ${String(count)} ${count === 1 ? 'field' : 'fields'} to continue.`;
      const focusFirst = first === undefined ? [] : [focus(`#${first}`)];
      return [{ ...s, errors: m.errors, summary }, focusFirst];
    },
    // In an app, this also returns the command that posts the form.
    SignedUp: (_s, m) => ({
      errors: NO_ERRORS,
      summary: '',
      account: { name: m.name, email: m.email },
    }),
    // While the user types, only a field that already shows an error is checked again,
    // so a message changes or disappears as soon as the value is fixed.
    Checked: (s, m) =>
      s.errors[m.field] === '' ? s : { ...s, errors: { ...s.errors, [m.field]: m.error } },
  },
  // No `value` bindings: what the user typed stays in the fields after a rejected submit.
  view: (s, i) => html`
    <form novalidate data-intent=${i.Submit}>
      <p role="alert">${s.summary}</p>

      <label for="name">Name</label>
      <input
        id="name"
        name="name"
        autocomplete="name"
        required
        aria-invalid=${s.errors.name !== ''}
        aria-describedby="name-error"
        data-intent=${i.Checked}
      />
      <p id="name-error">${s.errors.name}</p>

      <label for="email">Email</label>
      <input
        id="email"
        name="email"
        type="email"
        autocomplete="email"
        required
        aria-invalid=${s.errors.email !== ''}
        aria-describedby="email-error"
        data-intent=${i.Checked}
      />
      <p id="email-error">${s.errors.email}</p>

      <label for="password">Password</label>
      <input
        id="password"
        name="password"
        type="password"
        autocomplete="new-password"
        required
        minlength="8"
        aria-invalid=${s.errors.password !== ''}
        aria-describedby="password-error"
        data-intent=${i.Checked}
      />
      <p id="password-error">${s.errors.password}</p>

      <input
        id="consent"
        name="consent"
        type="checkbox"
        required
        aria-invalid=${s.errors.consent !== ''}
        aria-describedby="consent-error"
        data-intent=${i.Checked}
      />
      <label for="consent">I accept the privacy policy</label>
      <p id="consent-error">${s.errors.consent}</p>

      <button>Create account</button>
    </form>
    <p role="status">${welcome(s.account)}</p>
  `,
});

What each part does:

  • novalidate on the form, constraints on the fields. required, type="email" and minlength still set each field's validity; novalidate only stops the browser from blocking the submit with its own bubble. Without it, no submit event fires and your messages never appear.
  • The submit intent is the parser. data-intent=${i.Submit} on the <form> runs Submit on submit (and Gyral prevents the page load). target is the <form>, so target.elements.namedItem('email') is the email input, and formData holds the submitted values. Submit isn't a message tag, so it may return either message: Rejected with every field's error, or SignedUp with the trimmed values.
  • problemWith(input) turns validity into your words: valueMissing, typeMismatch, tooShort, and checked for the checkbox. It trims first, because required accepts a value of spaces.
  • Errors are state: one string per field, '' for none. The view writes each message in a <p> next to its field, links it with aria-describedby and sets aria-invalid.
  • The role="alert" paragraph announces the summary. It is always rendered, empty until a submit fails, so screen readers announce the text when it appears. role="status" announces the welcome the same way, politely.
  • Focus moves to the first invalid field with a focus() command returned from the Rejected reducer. Commands are data; the runtime moves the focus after rendering.
  • Fixing a field clears its error. Each control sends Checked (text fields on input, the checkbox on change); the reducer updates only a field that already shows an error, so nothing turns red while someone is still typing.
  • Values stay in the fields. The view binds no value, so a rejected submit leaves what the user typed. Bind value=${s.name} only when state must set the text, such as emptying a field after a submit.
  • Enter submits, because the form has a submit button, and a click submits the same way.
  • No password in state. SignedUp carries the name and the email only.

To adapt it, add the field's name to FIELDS, give it a case in problemWith, and render a labelled control with the same id and name plus its error <p>. To send the form to a server, keep the raw FormData in the message and return a command that posts it, as in Submitting to the server. The signup form recipe has the same component with its tests: the reducers without a browser, and the parser with mount().

There are two ways to validate a form. The example uses the first; add the second when a server needs the same rules.

  • Native validation, with no extra packages. The browser's constraints (required, type="email", minlength) find what is wrong, and the submit parser reads each field's validity and returns your messages. It works in any app, including a new basic one.
  • One schema for both sides. A Standard Schema library validates in the browser and on the server, the form posts with @gyral/http, and the same errors render without JavaScript.

Native validation

required without novalidate hides your errors. The browser checks constraints before it fires submit. When one fails, it shows its own bubble and fires no submit event, so your parser never runs and your messages never appear. Put novalidate on any form that shows its own errors, and keep the constraints on the fields: they still set validity, which the parser reads, as in the complete example.

Each constraint sets one validity flag, so the parser can say exactly what is wrong:

Constraint Flag Notes
required valueMissing A value of spaces passes: trim it
required on a checkbox valueMissing Set while it is unchecked
type="email", type="url" typeMismatch Only for a value that isn't empty
minlength, maxlength tooShort, tooLong Only after the user edits the value
pattern patternMismatch
min, max, step rangeUnderflow, rangeOverflow, stepMismatch Numbers, dates and times
type="number", unparsable text badInput The value reads as ''

validity.valid is true when no flag is set, and form.checkValidity() asks every field at once. A message set with setCustomValidity(), as the invalid() hook does, sets customError, so read the specific flags rather than valid when you combine them.

novalidate in server-rendered markup turns validation off for visitors without JavaScript too. If your form also posts to a server, render ?novalidate=${s.live} and set live in the Hydrated reducer, as in Schema messages for everything, and validate again on the server.

One schema for both sides

When a server handles the form too, validate it with one schema on both sides. Each layer is optional; use as many as a form needs:

  1. Native constraints: required, type="email", minlength, pattern.
  2. A schema-parsed intent: form(schema, toMsg) validates the submission.
  3. Errors in the model, mirrored into native validity with invalid().
  4. Server checks as ordinary commands: submitForm() posts the form.
  5. No-JavaScript parity: the server's formAction() validates with the same schema and renders the same errors.

This path needs @gyral/http for the post, @gyral/ssr for the server half and a schema library. Define the form once with any Standard Schema library, such as Zod, Valibot, ArkType or Effect Schema. Form values arrive as strings; the schema coerces them.

// src/signup-form.ts
import { defineForm } from '@gyral/core';
import * as v from 'valibot';

export const SignupForm = defineForm(
  v.object({
    email: v.pipe(v.string(), v.trim(), v.email('Enter an email address like ada@example.com.')),
    password: v.pipe(v.string(), v.minLength(8, 'Use at least 8 characters.')),
    invite: v.optional(v.string()),
  }),
  // Never echoed back after a failed submit. Fields named like `password` are secret already.
  { secret: ['invite'] },
);

The definition is also what the server uses: SignupForm.validate(data) checks a FormData the way form() does, and SignupForm.values(data) returns the submitted values to re-fill a form, without its secret fields.

Secret fields are never echoed. After a failed submit, the submitted values come back so the form can be re-filled, except fields listed in secret (any posted field name, not only the schema's) and any field whose name contains password, passcode, csrf or xsrf. They're left out of IntentRejected.values on both paths and out of values(), and the devtools timeline shows them as [secret]. Development builds warn once about an unlisted field whose name looks secret (token, card, cvc, …).

form() and invalid()

// src/signup.ts
import { define, fieldErrors, form, html, invalid } from '@gyral/core';
import { redirectedTo, submitForm } from '@gyral/http';
import { SignupForm } from './signup-form.js';

export interface State {
  readonly email: string;
  readonly errors: Readonly<Record<string, readonly string[]>>;
  readonly busy: boolean;
  readonly done: string | undefined;
}

export type Msg =
  | { readonly _tag: 'Submit'; readonly form: FormData }
  | { readonly _tag: 'Done'; readonly location: string | undefined }
  | { readonly _tag: 'Failed' };

export const Signup = define<State, Msg>()('my-signup', {
  init: () => ({ email: '', errors: {}, busy: false, done: undefined }),
  intent: {
    // Valid in the browser: post the raw FormData so the server parses it like a no-JS post.
    Submit: form(SignupForm, (_data, raw) => ({ _tag: 'Submit', form: raw })),
  },
  update: {
    Submit: (s, m) => [
      { ...s, errors: {}, busy: true },
      [
        submitForm('/signup', m.form, {
          onSuccess: (body): Msg => ({ _tag: 'Done', location: redirectedTo(body) }),
          onFailure: (): Msg => ({ _tag: 'Failed' }),
        }),
      ],
    ],
    Done: (s, m) => ({ ...s, busy: false, done: m.location ?? '/' }),
    Failed: (s) => ({ ...s, busy: false, errors: { '': ['Something went wrong. Try again.'] } }),
    // Browser-side and server-side rejections arrive as the same message.
    IntentRejected: (s, m) => ({
      ...s,
      busy: false,
      email: typeof m.values?.['email'] === 'string' ? m.values['email'] : s.email,
      errors: fieldErrors(m.issues),
    }),
  },
  view: (s, i) => html`
    <form data-intent=${i.Submit} action="/signup" method="post">
      <label for="email">Email</label>
      <input
        id="email"
        name="email"
        type="email"
        autocomplete="email"
        required
        value=${s.email}
        aria-describedby="email-error"
        ${invalid(s.errors['email'])}
      />
      <span id="email-error">${s.errors['email']?.join(' ') ?? ''}</span>

      <label for="password">Password</label>
      <input
        id="password"
        name="password"
        type="password"
        autocomplete="new-password"
        minlength="8"
        required
        aria-describedby="password-error"
        ${invalid(s.errors['password'])}
      />
      <span id="password-error">${s.errors['password']?.join(' ') ?? ''}</span>

      <p role="alert">${s.errors['']?.join(' ') ?? ''}</p>
      <button ?disabled=${s.busy}>Create account</button>
    </form>
  `,
});

What happens on submit:

  • The browser checks native constraints first. If required or type="email" fails, the browser shows its own message and no submit event fires, so form() doesn't run. Keep them: they work without JavaScript and are accessible. To show the schema's messages instead, see Schema messages for everything.
  • form(schema, toMsg) turns the FormData into a plain object (repeated names become arrays) and validates it. Valid data goes to toMsg, which also gets the raw FormData. Invalid data becomes IntentRejected, with one issue per field: path matches the field's name.
  • Errors live in state. fieldErrors(issues) groups them by field. Keep the result in state: it is data, so tests can see it.
  • invalid(errors) is an element hook that mirrors a field's errors into native validity: it calls setCustomValidity and sets aria-invalid, so :user-invalid styling, the browser's error bubble and your model agree. It clears the error when the user edits the field.
  • The server writes aria-invalid="true" too: the hook has a server half that adds it to the rendered start tag, so the no-JavaScript page announces errors. aria-describedby points at the message, which is plain text in the view.

field(schema, toMsg) does the same for a single control. Pair it with data-intent-on="input" to check while the user types.

Submitting to the server

Some checks only the server can make: the email is taken, the password is wrong. That's why the Submit reducer above posts the submission with submitForm(url, formData, handlers) from @gyral/http, once it passed the browser's checks:

  • The body is the FormData itself, so the server parses it exactly like a no-JS post.
  • A 422 answer with an IntentRejected body goes to your IntentRejected reducer, the same one that handles browser-side rejections.
  • The CSRF token comes from the http driver's headers, set once for the app: makeHttpDriver({ headers: csrfFromMeta('csrf-token') }) (Effects). csrf: { token } sends a token the app already holds.
  • The default concurrency is exhaust: a double click doesn't submit twice.
  • redirectedTo(body) reads the location when the server answered with a redirect.

The server half

On the server, formAction(definition, handlers) from @gyral/ssr handles the same route for both paths:

// server/app.ts
import { Hono } from 'hono';
import { html, type IntentRejected } from '@gyral/core';
import { formAction, rejectWith, renderPage, seeOther } from '@gyral/ssr';
import { SignupForm } from '../src/signup-form.js';

const taken = new Set(['ada@example.com']);

const signupPage = (rejected: IntentRejected | undefined, status = 200): Response =>
  renderPage(
    {
      title: 'Create an account',
      body: html`<main>
        <h1>Create an account</h1>
        <my-signup .initialMessages=${rejected === undefined ? [] : [rejected]}></my-signup>
      </main>`,
      scripts: ['/src/entry-client.ts'],
    },
    { status },
  );

export const app = new Hono();

app.get('/signup', () => signupPage(undefined));

app.post('/signup', (c) =>
  formAction(SignupForm, {
    intent: 'Submit', // the form's data-intent name
    valid: (data) =>
      taken.has(data.email)
        ? rejectWith([{ path: 'email', message: 'That email is already registered.' }])
        : seeOther('/welcome'),
    invalid: (rejected) => signupPage(rejected, 422),
  })(c.req.raw),
);
  • One validator. formAction runs the definition's validate, as form() does, so a submission is rejected with identical issues on either path.
  • One reducer. On failure, the server renders the component with initialMessages: [rejected]. The component's own IntentRejected reducer builds the error state during the server render, and the browser resumes from it.
  • Post/Redirect/Get. seeOther(url) answers 303, so reloading never resubmits.
  • JSON for the JavaScript path. When submitForm asks for JSON, a redirect becomes { _tag: 'Redirected', location } and a rejection a 422 without the submitted values. Browser form posts get HTML. Leave invalid out and every rejection is that JSON 422; wantsJson(request) tells your own routes which kind of request they got.
  • rejectWith(issues | message) rejects a schema-valid submission; a string is a form-level message (path '').

Never keep passwords in state either: server-rendered state is written into the page.

Schema messages for everything

Native constraints run before form(), so a missing required field shows the browser's wording, not your schema's. That's usually what you want. If you'd rather show the schema's messages for everything, keep the constraints in the server-rendered markup and add novalidate once the component is live:

<form data-intent="${i.Submit}" ?novalidate="${s.live}">…</form>

with Hydrated: (s) => ({ ...s, live: true }) in update. No-JavaScript visitors still get native validation, and every JavaScript submit reaches form().

File uploads

For a custom element that submits a native form value, see Custom form controls.

Use a native file input and keep the raw FormData from the parsed submit. This sends the file bytes instead of converting them to JSON:

// src/photo-upload.ts
import { define, defineForm, form, html } from '@gyral/core';
import { submitForm } from '@gyral/http';
import * as v from 'valibot';

const PhotoForm = defineForm(v.object({ photo: v.instance(File) }));
type State = { readonly status: string };
type Msg =
  | { readonly _tag: 'Submit'; readonly data: FormData }
  | { readonly _tag: 'Uploaded' }
  | { readonly _tag: 'Failed' };

export const PhotoUpload = define<State, Msg>()('photo-upload', {
  init: () => ({ status: 'Choose a photo.' }),
  intent: { Submit: form(PhotoForm, (_data, raw) => ({ _tag: 'Submit', data: raw })) },
  update: {
    Submit: (_s, m) => [
      { status: 'Uploading…' },
      [
        submitForm('/photos', m.data, {
          onSuccess: (): Msg => ({ _tag: 'Uploaded' }),
          onFailure: (): Msg => ({ _tag: 'Failed' }),
        }),
      ],
    ],
    Uploaded: () => ({ status: 'Photo uploaded.' }),
    Failed: () => ({ status: 'Upload failed. Try again.' }),
    IntentRejected: () => ({ status: 'Choose a photo before uploading.' }),
  },
  view: (s, i) => html`
    <form action="/photos" method="post" enctype="multipart/form-data" data-intent=${i.Submit}>
      <label for="photo">Photo</label>
      <input id="photo" name="photo" type="file" accept="image/*" required />
      <button type="submit">Upload</button>
      <output aria-live="polite">${s.status}</output>
    </form>
  `,
});

Do not set content-type yourself: the browser adds the multipart boundary. accept is a picker hint, not server validation. The server must enforce size, count, content and storage rules and apply authentication/CSRF checks. The schema above checks only that a file object exists; add your application's browser checks and validate again on the server. File inputs cannot be restored from a hydration seed or sticky rejected values; keep files in the submit boundary, not model state. See Security.