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:
novalidateon the form, constraints on the fields.required,type="email"andminlengthstill set each field'svalidity;novalidateonly stops the browser from blocking the submit with its own bubble. Without it, nosubmitevent fires and your messages never appear.- The submit intent is the parser.
data-intent=${i.Submit}on the<form>runsSubmitonsubmit(and Gyral prevents the page load).targetis the<form>, sotarget.elements.namedItem('email')is the email input, andformDataholds the submitted values.Submitisn't a message tag, so it may return either message:Rejectedwith every field's error, orSignedUpwith the trimmed values. problemWith(input)turnsvalidityinto your words:valueMissing,typeMismatch,tooShort, andcheckedfor the checkbox. It trims first, becauserequiredaccepts 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 witharia-describedbyand setsaria-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 theRejectedreducer. Commands are data; the runtime moves the focus after rendering. - Fixing a field clears its error. Each control sends
Checked(text fields oninput, the checkbox onchange); 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. Bindvalue=${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.
SignedUpcarries 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'svalidityand returns your messages. It works in any app, including a newbasicone. - 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:
- Native constraints:
required,type="email",minlength,pattern. - A schema-parsed intent:
form(schema, toMsg)validates the submission. - Errors in the model, mirrored into native validity with
invalid(). - Server checks as ordinary commands:
submitForm()posts the form. - 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
requiredortype="email"fails, the browser shows its own message and nosubmitevent fires, soform()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 theFormDatainto a plain object (repeated names become arrays) and validates it. Valid data goes totoMsg, which also gets the rawFormData. Invalid data becomesIntentRejected, with one issue per field:pathmatches the field'sname.- 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 callssetCustomValidityand setsaria-invalid, so:user-invalidstyling, 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-describedbypoints 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
FormDataitself, so the server parses it exactly like a no-JS post. - A
422answer with anIntentRejectedbody goes to yourIntentRejectedreducer, the same one that handles browser-side rejections. - The CSRF token comes from the
httpdriver'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.
formActionruns the definition'svalidate, asform()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 ownIntentRejectedreducer builds the error state during the server render, and the browser resumes from it. - Post/Redirect/Get.
seeOther(url)answers303, so reloading never resubmits. - JSON for the JavaScript path. When
submitFormasks for JSON, a redirect becomes{ _tag: 'Redirected', location }and a rejection a422without the submitted values. Browser form posts get HTML. Leaveinvalidout and every rejection is that JSON422;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.