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:
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
// 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
// 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-invalidandaria-describedbytie each message to its input for screen readers.noValidateleaves 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
traverseErrorswith 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
- Errors in the changesets guide
- Framework integration: Next.js server actions

