Skip to content
cennge-ui
GitHubv0.3.1, changelog

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.

Lowercase letters, digits and dashes
Visibility*
validationErrors
undefined
  1. Press Create repository with nothing filled in: each required field shows the browser's reason, and focus goes to the first.
  2. Name it cennge-ui and pick a visibility: the browser lets it through, and the server's answer lands under the name.
  3. Edit the name and its error goes at once. Below the form is the object the server returned.
Live: fill it in, submit, fix what it says.

Field marks

Import
import { Form } from "cennge-ui";
Size
1.1 KB gzip for this import, its dependencies aside
Pattern
Native <form>, with action or onSubmit
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

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 own isInvalid and errorMessage.
  • 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.

server-errors.tsx
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.

action.tsx
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.

Major, minor and patch
browser-checks.tsx
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.

Channel
own-field.tsx
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#

PropTypeDefault
validationErrors
FieldErrorsErrors by field name, e.g. returned by a server action: { email: "Already taken" }. Each shows under its field until the user edits that field; a new object shows the new set and focuses the first.
–

Also takes every attribute of <form>.