# Form

> Apply consistent structure to a native HTML form without hiding submission or validation logic.

Source: https://www.honestui.com/docs/components/form

```tsx
"use client";

import * as React from "react";

import { Button } from "@/components/honest-ui/ui/button";
import {
  Field,
  FieldControl,
  FieldError,
  FieldLabel,
} from "@/components/honest-ui/ui/field";
import { Form } from "@/components/honest-ui/ui/form";

export function FormDemo() {
  const [savedEmail, setSavedEmail] = React.useState("");

  const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    setSavedEmail(String(formData.get("email") ?? ""));
  };

  return (
    <Form onSubmit={onSubmit} className="grid w-full max-w-64 gap-4">
      <Field>
        <FieldLabel>Email address</FieldLabel>
        <FieldControl
          name="email"
          type="email"
          placeholder="you@example.com"
          required
        />
        <FieldError>
          Enter an email address in the format name@example.com.
        </FieldError>
      </Field>
      <Button type="submit">Save email address</Button>
      <p
        aria-live="polite"
        className="min-h-[var(--hui-space-5)] text-[length:var(--hui-font-size-mini)] text-[var(--hui-color-foreground-success-primary)]"
      >
        {savedEmail ? `Submitted email: ${savedEmail}.` : null}
      </p>
    </Form>
  );
}

```

## Overview [#overview]

Form is a thin wrapper around the native HTML `form` element — it renders `<form>` and forwards every prop unchanged.

Use it to keep Honest UI form markup consistent while your application continues to own submission, validation, pending state, server errors, and success feedback. Nothing is registered or submitted behind an abstraction: because it is a native form, browser constraint validation still runs unless you opt out, values are submitted under each control's `name`, and server actions passed to `action` work as they would on any `<form>`.

## Anatomy [#anatomy]

A form contains one native form root, its fields, and the actions that submit or reset it. Combine `Form` with `Field` and `Fieldset` when controls need labels, descriptions, grouped questions, or validation messages.

## Behavior [#behavior]

`Form` accepts the same props as a native `<form>`, including `action`, `method`, `onSubmit`, and `noValidate`. It does not collect values into an object or distribute server errors. Read values with `FormData`, a server action, or the form library your project already uses.

Because the wrapper is native, Field's submit-time machinery does not run automatically: wire per-field error display through Field's `validationMode`, `validate`, or `invalid` props, and let the browser's own constraint validation handle `required` and types when that is enough. See [Don't do this](#dont-do-this) for the most common way this goes wrong.

Keep entered values in place after an error. Show pending state on the submit action, associate field errors with their controls, and place a visible status message where users can confirm the result.

## Accessibility [#accessibility]

Use a submit button with `type="submit"`; Honest UI buttons otherwise default to `type="button"` and pressing <kbd>Enter</kbd> will not submit anything. Give every control a visible label, describe validation requirements before submission, and move focus only when users need to correct a specific problem — focus the first invalid field after a failed submission rather than restarting the page.

Announce asynchronous status with a persistent visible message plus `aria-live="polite"` (or `role="status"`), so screen reader users hear the outcome without the message disappearing from the screen.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;form&#x22;]" />
  

  
    
      
        No additional package dependency is required for Form.
      

      
        Copy and paste the following code into your project.
      

      ### components/ui/form.tsx

```tsx
"use client"

import * as React from "react"

type FormProps = React.ComponentProps<"form">

function Form(props: FormProps) {
  return <form {...props} />
}

export { Form }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import {
  Field,
  FieldControl,
  FieldError,
  FieldLabel,
} from "@/components/ui/field";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
```

```tsx
<Form
  onSubmit={(e) => {
    e.preventDefault();
    const values = new FormData(e.currentTarget);
    saveEmail(values.get("email"));
  }}
>
  <Field>
    <FieldLabel>Email</FieldLabel>
    <FieldControl name="email" type="email" required />
    <FieldError>Please enter a valid email.</FieldError>
  </Field>
  <Button type="submit">Save email address</Button>
</Form>
```

## Don't do this [#dont-do-this]

### Collecting values control by control [#collecting-values-control-by-control]

```tsx
// Bad
const emailRef = React.useRef<HTMLInputElement>(null);
const nameRef = React.useRef<HTMLInputElement>(null);

<Form onSubmit={() => save(emailRef.current?.value, nameRef.current?.value)}>
```

```tsx
// Good
<Form
  onSubmit={(event) => {
    event.preventDefault();
    const values = new FormData(event.currentTarget);
    save(values.get("email"), values.get("fullName"));
  }}
>
```

Per-control refs break the moment a field moves, becomes conditional, or gains a wrapper, and they silently drop every field someone adds later without a ref. `FormData` reads exactly what the browser would submit, keyed by `name`, including dynamically added fields.

### Submitting without feedback [#submitting-without-feedback]

```tsx
// Bad
<Form onSubmit={(event) => {
  event.preventDefault();
  fetch("/api/subscribe", { method: "POST" });
}}>
```

```tsx
// Good
<Form onSubmit={handleSubscribe}>
  {/* ...fields... */}
  <Button type="submit" disabled={pending}>
    {pending && <LoaderCircleIcon className="animate-spin" aria-hidden="true" />}
    Subscribe
  </Button>
  <p role="status">{status}</p>
</Form>
```

A fire-and-forget request leaves people staring at an unchanged form: no confirmation on success, no recovery path on failure, and a button that can be clicked five more times while the first request is still flying. Disable the action while pending, keep its label stable with a spinner, and report the outcome in a live region.

## Examples [#examples]

The first example shows native submission with visible, in-page feedback. Zod remains an optional application dependency in the second example; Form does not require or configure it.

### Using with Zod [#using-with-zod]

Client-side validation mapped back onto named fields.

```tsx
"use client";

import * as React from "react";
import { z } from "zod";

import { Button } from "@/components/honest-ui/ui/button";
import {
  Field,
  FieldControl,
  FieldError,
  FieldLabel,
} from "@/components/honest-ui/ui/field";
import { Form } from "@/components/honest-ui/ui/form";

const schema = z.object({
  name: z.string().min(1, { message: "Please enter a name." }),
  age: z.coerce
    .number({ message: "Please enter a number." })
    .positive({ message: "Number must be positive." }),
});

type Errors = Record<string, string | string[]>;

function validateForm(event: React.FormEvent<HTMLFormElement>) {
  event.preventDefault();

  const formData = new FormData(event.currentTarget);
  const result = schema.safeParse(Object.fromEntries(formData.entries()));

  if (!result.success) {
    const { fieldErrors } = z.flattenError(result.error);
    return { errors: fieldErrors as Errors };
  }

  return {
    errors: {} as Errors,
  };
}

export function FormZodDemo() {
  const [errors, setErrors] = React.useState<Errors>({});
  const [status, setStatus] = React.useState("");
  const clearError = (name: string) => {
    setErrors((current) => {
      if (!(name in current)) return current;
      const next = { ...current };
      delete next[name];
      return next;
    });
  };

  const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    const response = validateForm(event);
    setErrors(response.errors);
    if (Object.keys(response.errors).length === 0) {
      setStatus("Details are valid and ready to submit.");
    } else {
      setStatus("Check the highlighted fields.");
    }
  };

  return (
    <Form className="grid max-w-64 gap-4" onSubmit={onSubmit}>
      <Field name="name" invalid={Boolean(errors.name)}>
        <FieldLabel>Name</FieldLabel>
        <FieldControl
          placeholder="Enter name"
          onChange={() => {
            clearError("name");
            setStatus("");
          }}
        />
        <FieldError>{errors.name?.[0]}</FieldError>
      </Field>
      <Field name="age" invalid={Boolean(errors.age)}>
        <FieldLabel>Age</FieldLabel>
        <FieldControl
          placeholder="Enter age"
          onChange={() => {
            clearError("age");
            setStatus("");
          }}
        />
        <FieldError>{errors.age?.[0]}</FieldError>
      </Field>
      <Button type="submit">Validate details</Button>
      <p
        aria-live="polite"
        className="min-h-[var(--hui-space-5)] text-[length:var(--hui-font-size-mini)] text-[var(--hui-color-foreground-base-secondary)]"
      >
        {status}
      </p>
    </Form>
  );
}

```

### Newsletter capture [#newsletter-capture]

Structure and copy only; your application supplies the submission handler.

```tsx
import { Button } from "@/components/honest-ui/ui/button"
import { Field, FieldControl, FieldDescription, FieldLabel } from "@/components/honest-ui/ui/field"
import { Form } from "@/components/honest-ui/ui/form"

export function FormNewsletter() {
  return (
    <Form className="grid w-full max-w-sm gap-4 rounded-xl border p-4">
      <Field>
        <FieldLabel>Newsletter</FieldLabel>
        <FieldControl type="email" placeholder="you@example.com" />
        <FieldDescription>One concise product email each Friday.</FieldDescription>
      </Field>
      <Button type="submit">Subscribe</Button>
    </Form>
  )
}

```

### Server-side error mapping [#server-side-error-mapping]

Errors returned from the server are keyed by field `name` and routed to the matching `Field` through its `invalid` prop, while entered values stay in place.

<ComponentSource name="form-server-error" title="examples/form-server-error.tsx" />

## API reference [#api-reference]

`Form` accepts `React.ComponentProps<"form">` and forwards every prop to the native element. It adds no custom props or submission behavior.

Native props worth reaching for deliberately:

| Prop                 | Purpose                                                                                             |
| -------------------- | --------------------------------------------------------------------------------------------------- |
| `onSubmit`           | Read `new FormData(event.currentTarget)` and call `preventDefault()` for client-handled submissions |
| `action`             | Pass a server action or endpoint for progressive-enhancement submissions                            |
| `noValidate`         | Suppress browser constraint bubbles when your fields own all messaging                              |
| `method` / `encType` | Match what your endpoint expects for non-JS submissions                                             |

See the [MDN form documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form) for everything else the native element supports.
