ReactJS
11 Jul 2026

React 19 useActionState Form Validation with TypeScript

The awkward part of form validation is not checking whether an email contains an @. It is everything that happens after the check fails: keeping the useful input, placing the message beside the right field, blocking a second submit, and giving TypeScript one state shape it can trust. React 19's useActionState is a good fit for that job when validation happens at submit time.

The short version

  • Type the returned state first: Keep success, field errors, submitted values, and a form-level message in one serializable object.
  • Accept previousState before FormData: useActionState changes the Action signature by adding the previous state as the first argument.
  • Return expected validation failures: Do not throw an Error Boundary at a typo the user can fix.
  • Use isPending for the submit button: It belongs to the Action, so no extra loading state is required.
  • Validate again at the trusted boundary: Client-side feedback never replaces server-side validation.

A complete TypeScript example

This example stays deliberately small. It uses no schema library, so the relationship between FormData, the Action, and the returned state remains visible. Submit taken@example.comto exercise the API-style error path.
"use client";

import { useActionState } from "react";

type FieldErrors = Partial<
  Record<"email" | "password" | "terms", string>
>;

type SignUpState = {
  status: "idle" | "error" | "success";
  values: { email: string };
  errors: FieldErrors;
  message: string | null;
};

const initialState: SignUpState = {
  status: "idle",
  values: { email: "" },
  errors: {},
  message: null,
};

const wait = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function createAccount(
  previousState: SignUpState,
  formData: FormData,
): Promise<SignUpState> {
  const email = String(formData.get("email") ?? "").trim();
  const password = String(formData.get("password") ?? "");
  const acceptedTerms = formData.get("terms") === "on";
  const errors: FieldErrors = {};

  if (!/^\S+@\S+\.\S+$/.test(email)) {
    errors.email = "Enter a valid email address.";
  }

  if (password.length < 10) {
    errors.password = "Use at least 10 characters.";
  }

  if (!acceptedTerms) {
    errors.terms = "Accept the terms to continue.";
  }

  if (Object.keys(errors).length > 0) {
    return {
      status: "error",
      values: { email },
      errors,
      message: "Check the highlighted fields.",
    };
  }

  await wait(700);

  if (email.toLowerCase() === "taken@example.com") {
    return {
      status: "error",
      values: { email },
      errors: { email: "An account already uses this email." },
      message: "We could not create the account.",
    };
  }

  return {
    status: "success",
    values: { email: "" },
    errors: {},
    message: "Account created. Check your inbox.",
  };
}

export function SignUpForm() {
  const [state, formAction, isPending] = useActionState(
    createAccount,
    initialState,
  );

  return (
    <form action={formAction} noValidate>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        name="email"
        type="email"
        defaultValue={state.values.email}
        aria-invalid={Boolean(state.errors.email)}
        aria-describedby={state.errors.email ? "email-error" : undefined}
      />
      {state.errors.email && (
        <p id="email-error" role="alert">{state.errors.email}</p>
      )}

      <label htmlFor="password">Password</label>
      <input
        id="password"
        name="password"
        type="password"
        aria-invalid={Boolean(state.errors.password)}
        aria-describedby={state.errors.password ? "password-error" : undefined}
      />
      {state.errors.password && (
        <p id="password-error" role="alert">{state.errors.password}</p>
      )}

      <label>
        <input name="terms" type="checkbox" />
        I accept the terms
      </label>
      {state.errors.terms && (
        <p id="terms-error" role="alert">{state.errors.terms}</p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? "Creating account..." : "Create account"}
      </button>

      <p aria-live="polite">{state.message}</p>
    </form>
  );
}

Start with the state type, not the JSX

A loose state such as { message: string } feels convenient until the second field needs an error. A discriminated status and a fixed error map make the form's outcomes explicit. More importantly, the initial value and every Action return share the same SignUpState type. That prevents the commonNo overload matches this call error caused by returning incompatible shapes from different branches.Keep passwords and secrets out of returned state. The email is safe to return because it improves recovery after a failed submit; the password should be typed again rather than echoed back into markup or serialized server state.

Why previousState comes before FormData

A normal form Action receives FormData. Once that function is passed to useActionState, React prepends the previous Action state. The correct signature is therefore (previousState, formData). If the function only accepts one parameter, that parameter is the previous state—not the submitted fields.
async function action(
  previousState: SignUpState,
  formData: FormData,
): Promise<SignUpState> {
  const email = String(formData.get("email") ?? "");
  return previousState;
}
The example does not need previousState to validate a single submission, but keeping it in the signature is required. It becomes useful for multi-step Actions, attempt counters, or state that should survive across submissions.

Return field errors; throw unexpected failures

A missing checkbox is an expected outcome. Return it as data and put the message next to the checkbox. A database outage is not a field error. Log it at the trusted boundary and either return a safe form-level message or throw so the nearest Error Boundary can handle the failure.

A useful dividing line

  • Return from the Action: Invalid email, short password, duplicate account, expired invitation, or another failure the user can act on.
  • Throw or report outside the form: Network outage, unavailable database, broken invariant, malformed server response, or programmer error.

Keep useful values after a failed submission

React resets uncontrolled form fields after a form Action succeeds. From the browser's point of view, an Action that returns validation data still completed normally. Returning safe submitted values in the Action state and feeding them back through defaultValuegives the next render something useful to restore.Do this selectively. Preserve an email, display name, or search term. Do not preserve a password, card number, private token, or any field that should not reappear in serialized output.

Pending state without another useState

The third value returned by useActionState is isPending. Disable the submit button and change its label while the Action runs. That prevents accidental duplicate requests and tells the user that the click worked. If the button lives deep inside a reusable form component, useFormStatusis the better tool because it reads the surrounding form's status.

When useActionState is the wrong tool

Submit-time validation is not every form. A loan calculator that updates on each keystroke, a drag-and-drop form builder, or a large wizard with dependent fields still needs client state and a more deliberate validation model. useActionState shines when a user submits a coherent payload and the Action returns the next state of that submission.

Choose something else when

  • Validation must run on every keystroke: Controlled state or a form library will be clearer.
  • Fields depend heavily on each other before submit: Model that client interaction directly.
  • The form has complex arrays and nested editors: A dedicated form library can reduce bookkeeping.
  • Several Actions must run in parallel: useActionState queues calls because each receives the previous result.

Common TypeScript and Action mistakes

  • Reading FormData from the first parameter: useActionState passes previousState first and the submitted payload second.
  • Returning different state shapes: Annotate the Action return type and keep every branch compatible with initialState.
  • Trusting FormData values: A field can be missing or contain a File, so narrow values before validation.
  • Throwing ordinary validation errors: Return expected, actionable failures so the form can render them beside the field.
  • Adding a separate loading boolean: Use isPending or useFormStatus instead of synchronizing duplicate pending state.
  • Treating client checks as security: Repeat validation and authorization where the mutation is trusted and persisted.

Where this fits in React 19

useActionState is one part of React 19's Action model. The broader release also includes form Actions, useFormStatus, useOptimistic, and improved error handling. Read theReact 19.2 feature and upgrade guide for the release context. If the form should update the interface before the request finishes, continue with theuseOptimistic rollback guide.
React-focused tutorials: components, hooks, patterns, and the modern React ecosystem.