Skip to content

Showing form errors (React) ​

A sign-up form whose server action returns the changeset's errors, and fields that show them. The example uses React 19's useActionState and a server action, as in Next.js; the part that matters, the state the action returns, works with any framework.

Setup ​

Both parts below use these imports, the repo and the schema:

tsx
import { postgres } from "@egon-orm/postgres";
import { cast, createRepo, type ErrorMessages, Schema, t } from "egon-orm";
import { useActionState } from "react";

const repo = createRepo({ adapter: postgres({ connectionString: process.env.DATABASE_URL }) });

class User extends Schema("users", {
  id: t.integer().primaryKey().dbGenerated(),
  email: t.string(),
  name: t.string(),
}) {
  static changeset(user: User | typeof User, params: unknown) {
    return cast(user, params, ["email", "name"])
      .validateRequired(["email", "name"])
      .validateFormat("email", /^[^@\s]+@[^@\s]+$/)
      .uniqueConstraint("email");
  }
}

The action ​

tsx
// actions.ts
// "use server";
// Runs on the server. The state it returns is plain data, so it crosses to the client as it is.
// The errors of User.changeset, typed by field: { email?: string[]; name?: string[]; … }.
export type SignUpErrors = ErrorMessages<ReturnType<typeof User.changeset>>;

export type SignUpState = {
  values: Record<string, string>;
  errors: SignUpErrors;
  saved: boolean;
};

export async function signUp(_previous: SignUpState, form: FormData): Promise<SignUpState> {
  const values = { email: String(form.get("email") ?? ""), name: String(form.get("name") ?? "") };
  const result = await repo.insert(User.changeset(User, values));
  return result.success
    ? { values: {}, errors: {}, saved: true }
    : { values, errors: result.error.errorMessages(), saved: false }; // { email: ["has already been taken"] }
}

errorMessages() returns a plain object, { field: ["message", …] }, with validation errors and declared constraint violations in the same shape. It is serialisable, so it can be returned from a server action or an API as it is. It is typed by field: ErrorMessages<ReturnType<typeof User.changeset>> names it for the client, so a misspelt field is a compile error there too. The values go back as well, so the form keeps what the user typed.

The form ​

tsx
// SignUpForm.tsx
// "use client";
// A client component: it imports the server action, which Next.js turns into a request.
const initial: SignUpState = { values: {}, errors: {}, saved: false };

export function SignUpForm() {
  const [state, action, pending] = useActionState(signUp, initial);
  if (state.saved) return <p>Welcome!</p>;

  return (
    <form action={action} noValidate>
      <Field name="email" label="Email" state={state} />
      <Field name="name" label="Name" state={state} />
      <button type="submit" disabled={pending}>
        Sign up
      </button>
    </form>
  );
}

// The messages of one field. (A nested form, with castAssoc, has a tree under its key instead.)
function messagesOf(errors: SignUpErrors, field: FieldName): readonly string[] {
  const entry = errors[field];
  return Array.isArray(entry) ? entry.filter((e): e is string => typeof e === "string") : [];
}

/** The fields the form shows; a misspelt name is a compile error. */
type FieldName = ("email" | "name") & keyof SignUpErrors;

function Field({ name, label, state }: { name: FieldName; label: string; state: SignUpState }) {
  const errors = messagesOf(state.errors, name);
  const errorId = `${name}-errors`;
  return (
    <p>
      <label htmlFor={name}>{label}</label>
      <input
        id={name}
        name={name}
        defaultValue={state.values[name] ?? ""}
        aria-invalid={errors.length > 0}
        aria-describedby={errors.length > 0 ? errorId : undefined}
      />
      {errors.length > 0 && (
        <span id={errorId} role="alert">
          {label} {errors.join(", ")}
        </span>
      )}
    </p>
  );
}
  • aria-invalid and aria-describedby tie each message to its input for screen readers.
  • noValidate leaves the checking to the changeset, so the messages are the same with and without JavaScript.
  • A nested form (castAssoc) has a list of trees under the association's key, one per item, in the order of the submitted items.

Good to know

  • The messages are English templates with their values filled in. To translate them, use traverseErrors with a catalogue: see Translating error messages.
  • An error on the record as a whole (from a cross-field validator) is under the key "$base" (BASE).

See also ​