---
title: "React: Forms"
slug: "react-forms"
category: "frontend"
tags: ["frontend", "react", "forms", "server-actions", "validation"]
status: "stable"
last_updated: 2026-05-14
summary: "Uncontrolled forms by default, server actions for mutations, FormData over hand-rolled JSON, Zod for runtime schema, controlled only when validation needs it."
related: ["[[frontend/react]]", "[[frontend/forms]]", "[[frontend/nextjs]]", "[[frontend/react-server-components]]", "[[frontend/react-state-management]]", "[[coding/typescript]]"]
---

## Overview

React form patterns in 2026 are simpler than the controlled-input drills of the hooks era. Default to uncontrolled inputs and server actions; lift to controlled only when interactive validation requires it. The native `<form>` element is a complete submission primitive, and React 19 wired it into the server-action contract. This page covers the rules that keep forms accessible, typesafe, and resilient. For the framework-agnostic baseline, see [[frontend/forms]].

## Default to uncontrolled inputs

An uncontrolled input owns its own value; React reads it on submit via `FormData`. No `useState`, no re-render per keystroke, no controlled-input boilerplate.

```tsx
function NewNote() {
  return (
    <form action={createNote}>
      <label htmlFor="title">Title</label>
      <input id="title" name="title" required />
      <button type="submit">Save</button>
    </form>
  );
}
```

Each input renders once. The browser tracks the value; React reads it on submit. Reach for controlled inputs only when the value drives other UI (a live preview, a character counter, a dependent field), or when validation must run on every keystroke.

## Use server actions for mutations

A [[frontend/nextjs-server-actions|server action]] is a function marked `"use server"` that the React 19 framework wires up as the form's submit handler. The form posts directly to it without an ad-hoc API route.

```tsx
// app/notes/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";

const Schema = z.object({ title: z.string().min(1).max(200) });

export async function createNote(formData: FormData) {
  const parsed = Schema.parse(Object.fromEntries(formData));
  await db.note.create({ data: parsed });
  revalidatePath("/notes");
}
```

The form works without JavaScript: the browser submits, the server runs the action, the page re-renders. With JavaScript, the framework intercepts and avoids the full-page navigation. Same code, two execution paths. See [[frontend/react-server-components]] for the surrounding model and [[frontend/nextjs]] for the App Router specifics.

## Read fields with `FormData`, not hand-rolled JSON

`FormData` captures every named field at once: files, multi-select values, grouped inputs, checkboxes. Pass it directly to the server action; convert to a plain object with `Object.fromEntries(formData)` when the validator expects one.

```ts
const data = Object.fromEntries(formData);
const parsed = Schema.parse(data);
```

Hand-rolling `JSON.stringify({ title: state.title })` drifts from the form every time a field is added or renamed. `FormData` stays in sync because the names are the source of truth.

## Validate with Zod on the server, mirror on the client

Define one Zod schema per form; export it from the action module. Run it on the server inside the action; the same schema can run on the client for instant feedback.

```ts
export const NoteSchema = z.object({
  title: z.string().min(1, "Title is required").max(200),
  body: z.string().max(10_000).optional(),
});
```

The server validation is the trust boundary. The client validation is UX, not a gate. Never trust a client-only check; an attacker, a broken script, or a stale page bypasses it. See [[frontend/forms]] for the wider rule.

## Lift to controlled inputs when validation is interactive

When a field needs validation on every keystroke (password strength meter, slug availability check, character counter, dependent field that disables a button), control the input.

```tsx
"use client";
function PasswordField() {
  const [pw, setPw] = useState("");
  const strength = scoreStrength(pw);
  return (
    <>
      <input
        name="password"
        type="password"
        value={pw}
        onChange={(e) => setPw(e.target.value)}
        autoComplete="new-password"
        minLength={12}
      />
      <Meter score={strength} />
    </>
  );
}
```

Controlled inputs cost a render per keystroke. Use `useDeferredValue` from [[frontend/react-suspense]] when the validator is expensive.

## Show pending and error state with `useFormStatus` and `useActionState`

`useFormStatus` reads the pending state of the enclosing form; use it inside the submit button. `useActionState` wraps a server action and returns the last result, so the form can render server-side validation errors without a separate route.

```tsx
"use client";
import { useFormStatus } from "react-dom";
import { useActionState } from "react";

function Submit() {
  const { pending } = useFormStatus();
  return <button type="submit" disabled={pending}>{pending ? "Saving..." : "Save"}</button>;
}

function NoteForm() {
  const [state, action] = useActionState(createNote, { errors: {} });
  return (
    <form action={action}>
      <input name="title" />
      {state.errors.title && <p role="alert">{state.errors.title}</p>}
      <Submit />
    </form>
  );
}
```

The button stays focusable while pending so assistive tech reports the wait. See [[frontend/forms]] for why disabling submit on click is wrong.

## Never store secrets in form state

A controlled-form `useState` lives in the React tree, the DevTools, and any error logger that captures component state. Passwords, tokens, credit card numbers, and one-time codes belong in uncontrolled inputs, read once via `FormData`, sent to the server, and never echoed back.

```tsx
// Wrong: password lives in state and may surface in Sentry breadcrumbs.
const [password, setPassword] = useState("");

// Right: uncontrolled, read on submit, discarded.
<input name="password" type="password" autoComplete="new-password" />
```

For redaction at the logger level, see [[frontend/react-error-boundaries]] and the Sentry configuration there. For typed end-to-end payloads, see [[coding/typescript]].

## Related

- [[frontend/react]]
- [[frontend/forms]]
- [[frontend/nextjs]]
- [[frontend/react-server-components]]
- [[frontend/react-state-management]]
- [[frontend/react-error-boundaries]]
- [[coding/typescript]]
