Form validation
Build an accessible signup form with a store for the values, derived error messages, touched state and a submit button that follows validity.
This recipe builds a three-field signup form. It demonstrates useStore for form state, error messages that the compiler
keeps live as derived constants, per-field touched state, and the ARIA wiring (aria-invalid, aria-describedby,
role="alert") that makes the errors reach screen readers.
The component#
validate is a pure function from values to a record of messages, so it is the same function a server would run. In
SignupForm, const errors = validate(values) is a derived constant: the compiler turns it into a memo, so reading
errors.email in JSX or in a handler always sees the current result, and it re-runs only when a value it read changes
(see forgiving reactivity). Each field shows its message only after it has been touched.
import { snapshot, useId, useStore } from "@shadow-js/core";
import { Show } from "@shadow-js/web";
export interface Values {
name: string;
email: string;
password: string;
}
type Field = keyof Values;
type Errors = Partial<Record<Field, string>>;
export function validate(values: Values): Errors {
const errors: Errors = {};
if (values.name.trim() === "") errors.name = "Name is required";
if (!/^\S+@\S+\.\S+$/.test(values.email)) errors.email = "Enter a valid email address";
if (values.password.length < 8) errors.password = "Use at least 8 characters";
return errors;
}
interface TextFieldProps {
readonly label: string;
readonly type: "text" | "email" | "password";
readonly value: string;
readonly error: string | undefined;
readonly onInput: (value: string) => void;
readonly onBlur: () => void;
}
function TextField(props: TextFieldProps) {
const errorId = useId();
return (
<div>
<label>
{props.label}
<input
type={props.type}
value={props.value}
aria-invalid={props.error !== undefined}
aria-describedby={props.error === undefined ? undefined : errorId}
onInput={(event) => props.onInput(event.currentTarget.value)}
onBlur={() => props.onBlur()}
/>
</label>
<Show when={props.error}>{(message) => <p id={errorId} role="alert">{message()}</p>}</Show>
</div>
);
}
export function SignupForm(props: { onSubmit: (values: Values) => void }) {
const [values, setValues] = useStore<Values>({ name: "", email: "", password: "" });
const [touched, setTouched] = useStore({ name: false, email: false, password: false });
const errors = validate(values);
const invalid = Object.keys(errors).length > 0;
const shown = (field: Field): string | undefined => (touched[field] ? errors[field] : undefined);
const field = (label: string, name: Field, type: TextFieldProps["type"]) => (
<TextField
label={label}
type={type}
value={values[name]}
error={shown(name)}
onInput={(value) => setValues(name, value)}
onBlur={() => setTouched(name, true)}
/>
);
return (
<form
onSubmit={(event) => {
event.preventDefault();
setTouched({ name: true, email: true, password: true });
if (invalid) return;
props.onSubmit(snapshot(values));
}}
>
{field("Name", "name", "text")}
{field("Email", "email", "email")}
{field("Password", "password", "password")}
<button type="submit" disabled={invalid}>
Create account
</button>
</form>
);
}
export function App() {
const [welcome, setWelcome] = useStore("");
return (
<div>
<SignupForm onSubmit={(values) => setWelcome(`Welcome, ${values.name}`)} />
<p role="status">{welcome()}</p>
</div>
);
}
Decisions#
One store for values, one for touched. useStore on a plain object returns a deep, fine-grained store.
values[name] subscribes only to that property, so typing in the email field re-evaluates the email binding and the
memo behind errors, not the name field. The setter accepts a path: setValues(name, value) writes one property, and
setTouched({ name: true, ... }) merges several.
Errors are derived, not stored. There is no errors state to keep in sync. Because errors is a memo,
invalid is derived from it, and disabled={invalid} follows without an effect. validate reads
values.name, values.email and values.password inside the memo, so those are its dependencies.
Touched. An error appears after the field loses focus, or after a submit attempt marks every field touched. While
the user is still typing in an untouched field the form stays quiet. The button is disabled while invalid is true, so
the submit handler repeats the check: a form can also be submitted with Enter or programmatically, and the handler
must not trust the button.
ARIA. aria-invalid is "true" or "false" (attributes whose name starts with aria- write false as
"false" instead of removing the attribute). aria-describedby points at the message by an id from useId and is
absent when there is no message. The message is inserted with role="alert", so assistive technology announces it
when it appears.
snapshot. The submit handler passes snapshot(values), a plain copy that is independent of later writes. Handing
the store itself to application code would leak a live, read-only proxy.
Related: the Forms guide, useStore, snapshot,
useId, useMemo, Show,
fireEvent, userEvent.