[]previous and next page

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.

For agentsUpdated

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.

src/signup-form.tsxtsx
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.