Skip to content

Patterns

Forms & validation

Build forms with React Aria's Form, native and custom validation, server errors, Zod schemas and Next.js server actions.

Fields in Desyne are React Aria fields with a label, description and error built in. Put them in React Aria's Form and you get native HTML validation, accessible error messages and focus on the first invalid field, with no form library.

A basic form

components/signup-form.tsxtsx
"use client";

import { Form } from "react-aria-components";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { TextField } from "@/components/ui/text-field";

export function SignupForm() {
  return (
    <Form
      className="flex max-w-sm flex-col gap-4"
      onSubmit={(e) => {
        e.preventDefault();
        const data = Object.fromEntries(new FormData(e.currentTarget));
        console.log(data); // { name, email, terms }
      }}
    >
      <TextField label="Name" name="name" autoComplete="name" isRequired />
      <TextField
        label="Work email"
        name="email"
        type="email"
        autoComplete="email"
        description="We'll send the invite here."
        isRequired
      />
      <Checkbox name="terms" value="yes" isRequired>
        I agree to the terms
      </Checkbox>
      <Button type="submit">Create account</Button>
    </Form>
  );
}

Every field takes a name, so FormData works as in plain HTML. Required fields get a red asterisk on the label.

How validation behaves

BehaviourvalidationBehavior="native" (default)validationBehavior="aria"
Blocks submit when invalidYesNo; you decide
When errors showAfter the first submit attempt, then live as values changeImmediately, as values change
Built-in rules (isRequired, type="email", minLength, pattern, minValue)Yes, with the browser's localised messagesExposed as aria-invalid, no messages
Focus first invalid field on submitYesNo

Set it on the Form to apply to every field, or on a single field.

Custom rules

Use validate for rules that HTML can't express. Return a string (or an array of strings) to mark the field invalid, or null/true when valid:

tsx
<TextField
  label="Username"
  name="username"
  isRequired
  validate={(value) =>
    /^[a-z0-9-]{3,20}$/.test(value)
      ? null
      : "3–20 lowercase letters, numbers or dashes."
  }
/>

Override the browser's built-in messages with errorMessage, which also accepts a function of the validation state:

tsx
<TextField
  label="Email"
  type="email"
  isRequired
  errorMessage={({ validationDetails }) =>
    validationDetails.valueMissing ? "Enter your email." : "That doesn't look like an email."
  }
/>

Server errors

Pass errors keyed by field name to validationErrors. They show immediately, and each clears as soon as the user edits that field.

tsx
"use client";

import { useState } from "react";
import { Form } from "react-aria-components";
import { Button } from "@/components/ui/button";
import { TextField } from "@/components/ui/text-field";

export function WorkspaceForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [pending, setPending] = useState(false);

  return (
    <Form
      validationErrors={errors}
      onSubmit={async (e) => {
        e.preventDefault();
        setPending(true);
        const res = await fetch("/api/workspaces", {
          method: "POST",
          body: new FormData(e.currentTarget),
        });
        setErrors(res.ok ? {} : (await res.json()).errors);
        setPending(false);
      }}
      className="flex max-w-sm flex-col gap-4"
    >
      <TextField label="Workspace name" name="name" isRequired />
      <TextField label="URL" name="slug" prefix="acme.app/" isRequired />
      <Button type="submit" isPending={pending}>
        Create workspace
      </Button>
    </Form>
  );
}

isPending shows a spinner in the button, keeps its width, and ignores further presses while the request runs. See the live version in Text Field: server errors.

With Zod

Validate on the server with a Zod 4 schema and return its field errors in the same shape:

lib/schemas.tsts
import { z } from "zod";

export const inviteSchema = z.object({
  email: z.email("Enter a valid email."),
  role: z.enum(["admin", "member", "viewer"], { message: "Choose a role." }),
  seats: z.coerce.number().int().min(1).max(50),
});
app/actions.tsts
"use server";

import { z } from "zod";
import { inviteSchema } from "@/lib/schemas";

export type InviteState = { errors: Record<string, string[]>; ok?: boolean };

export async function invite(_prev: InviteState, formData: FormData): Promise<InviteState> {
  const parsed = inviteSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { errors: z.flattenError(parsed.error).fieldErrors as Record<string, string[]> };
  }
  // await db.invites.create(parsed.data)
  return { errors: {}, ok: true };
}

Wire it up with useActionState. React Aria accepts string | string[] per field:

app/invite-form.tsxtsx
"use client";

import { useActionState } from "react";
import { Form } from "react-aria-components";
import { Button } from "@/components/ui/button";
import { NumberField } from "@/components/ui/number-field";
import { Select, SelectItem } from "@/components/ui/select";
import { TextField } from "@/components/ui/text-field";
import { invite } from "./actions";

export function InviteForm() {
  const [state, action, pending] = useActionState(invite, { errors: {} });

  return (
    <Form action={action} validationErrors={state.errors} className="grid max-w-sm gap-4">
      <TextField label="Email" name="email" type="email" isRequired />
      <Select label="Role" name="role" isRequired placeholder="Choose a role">
        <SelectItem id="admin">Admin</SelectItem>
        <SelectItem id="member">Member</SelectItem>
        <SelectItem id="viewer">Viewer</SelectItem>
      </Select>
      <NumberField label="Seats" name="seats" defaultValue={1} minValue={1} maxValue={50} />
      <Button type="submit" isPending={pending}>Send invite</Button>
    </Form>
  );
}

Native rules (isRequired, type="email", minValue) still run in the browser first, so most mistakes never reach the server. The schema is the source of truth on the server.

Client-side Zod

To validate with the same schema while typing, call safeParse on one field inside validate: validate={(v) => inviteSchema.shape.email.safeParse(v).error?.issues[0]?.message}.

Other form libraries

React Hook Form, TanStack Form and Conform all work. Use controlled props (value/onChange, or isSelected for checkboxes and selectedKey for selects) and pass the library's error to isInvalid and errorMessage:

tsx
import { Controller, useForm } from "react-hook-form";

const { control, handleSubmit } = useForm<{ email: string }>();

<Controller
  control={control}
  name="email"
  rules={{ required: "Enter your email." }}
  render={({ field, fieldState }) => (
    <TextField
      label="Email"
      value={field.value ?? ""}
      onChange={field.onChange}
      onBlur={field.onBlur}
      name={field.name}
      isInvalid={fieldState.invalid}
      errorMessage={fieldState.error?.message}
      validationBehavior="aria"
    />
  )}
/>

Layout tips

  • Stack fields with gap-4; group related fields under a heading and a Separator.
  • Put two short fields side by side with grid gap-4 sm:grid-cols-2.
  • Keep the primary button at the end and align it with the fields' left edge.
  • Use description for format hints instead of placeholder text, which disappears.