Form
A form that shows every error under its field: your server's, matched by name, and the browser's own checks instead of its tooltips.
undefined
- Press Create repository with nothing filled in: each required field shows the browser's reason, and focus goes to the first.
- Name it cennge-ui and pick a visibility: the browser lets it through, and the server's answer lands under the name.
- Edit the name and its error goes at once. Below the form is the object the server returned.
Field marks
- Import
import { Form } from "cennge-ui";- Size
- 1.1 KB gzip for this import, its dependencies aside
- Pattern
- Native
<form>, withactionoronSubmit - State
- Errors by field name in
validationErrors; values stay in the fields - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Validation
- The browser's checks, shown under each field; schemas stay on your server
- Fields
- Every named field in Inputs and in Dates and times except Switch and Slider; your own through
useFormField - Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: form
- Source
- src/components/form
When to use#
Wrap fields in a Form when some of their errors come from elsewhere: a server action, an API, a check only the server can make, such as whether a name is taken. Return the errors as an object keyed by field name and pass it as validationErrors. Each one shows under its field and goes away as soon as that field is edited.
Form adds no validation of its own. isRequired, type="email", minLength and pattern stay the browser's checks, shown under the field rather than in a tooltip; a schema library such as zod stays on the server and sends its errors back by name.
Similar#
- A plain
<form>takes the same fields. The browser then shows its checks as tooltips, and a server error reaches a field only through that field's ownisInvalidanderrorMessage. - Forms is the guide to fields in forms: controlled or not, and with react-hook-form. This page is about the component.
Errors from your server#
Pass the errors the server returned, keyed by name. Each new object shows its whole set and moves focus to the first field with an error, in the order of the form. Several messages for one field, as an array, show together. Send it as it is: dev@example.com is a collaborator already. Then try another address.
import { useState } from "react";
import { Button, Form, Input, Select, SelectItem, toast } from "cennge-ui";
type Errors = Record<string, string>;
// Stands in for your API: it answers with errors by field name, or with none.
async function invite(data: FormData): Promise<{ errors?: Errors }> {
await new Promise((resolve) => setTimeout(resolve, 600));
if (data.get("email") === "dev@example.com") {
return { errors: { email: "dev@example.com is already a collaborator on payload-cache" } };
}
return {};
}
export default function InviteCollaborator() {
const [errors, setErrors] = useState<Errors>();
const [isPending, setPending] = useState(false);
return (
<Form
validationErrors={errors}
onSubmit={async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
const result = await invite(data);
setPending(false);
setErrors(result.errors);
if (!result.errors) toast.success("Invitation sent", { description: String(data.get("email")) });
}}
className="flex w-full max-w-96 flex-col gap-4"
>
<Input name="email" label="Email" type="email" defaultValue="dev@example.com" isRequired />
<Select name="role" label="Role" defaultValue="write">
<SelectItem value="read">Read</SelectItem>
<SelectItem value="triage">Triage</SelectItem>
<SelectItem value="write">Write</SelectItem>
<SelectItem value="maintain">Maintain</SelectItem>
</Select>
<Button type="submit" color="primary" isLoading={isPending} className="self-start">
Invite
</Button>
</Form>
);
}With a React 19 action#
With useActionState the errors are part of the action's state. React empties the form after every action, so give the field the submitted value back as defaultValue, with a key that makes it take it. Server errors survive that reset: they arrive at the same moment.
import { useActionState } from "react";
import { Button, Form, Input } from "cennge-ui";
interface RenameState {
name: string;
error?: string;
renamed?: boolean;
}
// In a Next.js app this is a server action, in a file that starts with "use server".
async function rename(_previous: RenameState, data: FormData): Promise<RenameState> {
await new Promise((resolve) => setTimeout(resolve, 600));
const name = String(data.get("name"));
if (name === "ai-niche-scout") return { name, error: "A repository with this name already exists" };
return { name, renamed: true };
}
export default function RenameRepository() {
const [state, action, isPending] = useActionState(rename, { name: "ai-niche-scout" });
return (
<Form
action={action}
validationErrors={state.error ? { name: state.error } : undefined}
className="flex w-full max-w-96 flex-col gap-4"
>
{/* React empties the form after an action; the key and defaultValue put the typed name back. */}
<Input key={state.name} name="name" label="Repository name" defaultValue={state.name} isRequired />
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" isLoading={isPending}>
Rename
</Button>
{state.renamed ? <span className="text-sm text-success-text">Renamed to {state.name}</span> : null}
</div>
</Form>
);
}The browser's checks#
Publish it empty. The form cancels the browser's invalid events, so each message appears under its field, in the browser's own words and language, and focus goes to the first field to fix. While you type, the message follows the field and goes once the value passes.
import { useState } from "react";
import { Button, Form, Input, Textarea } from "cennge-ui";
export default function PublishRelease() {
const [published, setPublished] = useState<string | null>(null);
return (
<Form
onSubmit={(event) => {
event.preventDefault();
setPublished(String(new FormData(event.currentTarget).get("version")));
}}
className="flex w-full max-w-96 flex-col gap-4"
>
<Input
name="version"
label="Version"
placeholder="1.4.0"
description="Major, minor and patch"
pattern="\d+\.\d+\.\d+"
isRequired
/>
<Input name="notify" label="Notify" type="email" placeholder="dev@example.com" />
<Textarea name="notes" label="Release notes" minLength={20} isRequired />
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" color="primary">
Publish payload-cache
</Button>
{published ? <span className="text-sm text-success-text">Published {published}</span> : null}
</div>
</Form>
);
}Your own field#
useFormField(name) hands a field of yours the form's error for that name, and its markEdited drops the error once the value changes. Set data-field-name on the field's wrapper, so the form can find the field and focus it.
import { useState } from "react";
import { Button, Form, ToggleGroup, ToggleGroupItem, toast, useFormField } from "cennge-ui";
/** A ToggleGroup that takes part in a Form: its value is submitted, and errors for its name show under it. */
function ChannelField({ name }: { name: string }) {
const [channel, setChannel] = useState<string | null>(null);
const field = useFormField(name);
return (
// data-field-name lets the form find the field and focus it when it has an error.
<div data-field-name={name} className="flex flex-col gap-1.5">
<span className={field.isInvalid ? "text-sm font-medium text-danger-text" : "text-sm font-medium"}>Channel</span>
<ToggleGroup
aria-label="Channel"
value={channel}
onValueChange={(next) => {
setChannel(next);
field.markEdited();
}}
>
<ToggleGroupItem value="latest">latest</ToggleGroupItem>
<ToggleGroupItem value="next">next</ToggleGroupItem>
<ToggleGroupItem value="beta">beta</ToggleGroupItem>
</ToggleGroup>
<input type="hidden" name={name} value={channel ?? ""} />
{field.isInvalid ? (
<p role="alert" className="text-xs text-danger-text">
{field.errorMessage}
</p>
) : null}
</div>
);
}
export default function PublishChannel() {
const [errors, setErrors] = useState<Record<string, string>>();
return (
<Form
validationErrors={errors}
onSubmit={(event) => {
event.preventDefault();
const channel = new FormData(event.currentTarget).get("channel");
setErrors(channel ? undefined : { channel: "Pick the tag to publish under" });
if (channel) toast.success(`payload-cache@1.4.0 is on ${channel}`);
}}
className="flex flex-col items-start gap-4"
>
<ChannelField name="channel" />
<Button type="submit" color="primary">
Publish
</Button>
</Form>
);
}Accessibility#
A field with an error gets aria-invalid, and the message is tied to it with aria-describedby, so screen readers read the two together. Focus goes to the first field with an error both when the browser blocks a submit and when a new set of errors arrives.
Props#
Also takes every attribute of <form>.