React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

react-19form-actionsuseActionStateuseFormStatusformsprogressive-enhancement

Form Actions - Wire async functions to forms with built-in pending and error state

Recipe

"use client";
 
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
 
async function subscribe(_prev: string, formData: FormData): Promise<string> {
  const email = formData.get("email") as string;
  const res = await fetch("/api/subscribe", {
    method: "POST",
    body: JSON.stringify({ email }),
  });
  if (!res.ok) return "Something went wrong.";
  return "Subscribed!";
}
 
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Subscribing..." : "Subscribe"}
    </button>
  );
}
 
export default function NewsletterForm() {
  const [message, formAction, isPending] = useActionState(subscribe, "");
 
  return (
    <form action={formAction}>
      <input name="email" type="email" required placeholder="you@example.com" />
      <SubmitButton />
      {message && <p>{message}</p>}
    </form>
  );
}

When to reach for this: Whenever you have a form that submits data -- signups, search, CRUD operations. Form actions replace the manual onSubmit + preventDefault + useState pattern.

Working Example

// A contact form with validation, error display, and progressive enhancement
"use client";
 
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
 
type FormState = {
  success: boolean;
  errors: Record<string, string>;
  message: string;
};
 
const initialState: FormState = {
  success: false,
  errors: {},
  message: "",
};
 
async function submitContact(_prev: FormState, formData: FormData): Promise<FormState> {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const body = formData.get("body") as string;
 
  // Client-side validation
  const errors: Record<string, string> = {};
  if (!name || name.length < 2) errors.name = "Name must be at least 2 characters.";
  if (!email || !email.includes("@")) errors.email = "Please enter a valid email.";
  if (!body || body.length < 10) errors.body = "Message must be at least 10 characters.";
 
  if (Object.keys(errors).length > 0) {
    return { success: false, errors, message: "Please fix the errors below." };
  }
 
  try {
    const res = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name, email, body }),
    });
 
    if (!res.ok) throw new Error("Server error");
 
    return { success: true, errors: {}, message: "Message sent! We'll be in touch." };
  } catch {
    return { success: false, errors: {}, message: "Failed to send. Please try again." };
  }
}
 
function FieldError({ error }: { error?: string }) {
  if (!error) return null;
  return <p className="text-red-500 text-sm mt-1">{error}</p>;
}
 
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type="submit"
      disabled={pending}
      className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
    >
      {pending ? "Sending..." : "Send Message"}
    </button>
  );
}
 
export default function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitContact, initialState);
 
  return (
    <form action={formAction} className="space-y-4 max-w-md">
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" className="border w-full p-2 rounded" />
        <FieldError error={state.errors.name} />
      </div>
 
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" className="border w-full p-2 rounded" />
        <FieldError error={state.errors.email} />
      </div>
 
      <div>
        <label htmlFor="body">Message</label>
        <textarea id="body" name="body" rows={4} className="border w-full p-2 rounded" />
        <FieldError error={state.errors.body} />
      </div>
 
      <SubmitButton />
 
      {state.message && (
        <p className={state.success ? "text-green-600" : "text-red-600"}>
          {state.message}
        </p>
      )}
    </form>
  );
}

What this demonstrates:

  • useActionState managing form state across submissions (errors, success message)
  • useFormStatus in a child component to show pending state on the submit button
  • Field-level error display from the action's return value
  • Progressive enhancement -- the form works without JavaScript if paired with a server action

Deep Dive

How It Works

  • <form action={fn}> -- React 19 extends the native action attribute to accept async functions. When the form is submitted, React calls fn(formData) and manages the pending lifecycle.
  • useActionState(action, initialState, permalink?) -- Wraps an action function and returns [state, wrappedAction, isPending]. Each time the form is submitted, action(prevState, formData) is called and the returned value becomes the new state. The optional permalink parameter enables progressive enhancement for server actions.
  • useFormStatus() -- Must be called from a component rendered inside a <form>. Returns { pending, data, method, action } reflecting the submission state of the nearest parent form.
  • Progressive enhancement -- When using server actions with useActionState, forms can work before JavaScript loads. The permalink argument specifies where to redirect after the server action completes in the no-JS case.
  • isPending from useActionState reflects whether the action is currently executing. This is available in the same component that calls useActionState, unlike useFormStatus which only works in descendants.

Variations

Multiple submit buttons with different actions:

"use client";
 
import { useActionState } from "react";
 
export default function ItemForm() {
  const [saveResult, saveAction] = useActionState(saveItem, null);
  const [deleteResult, deleteAction] = useActionState(deleteItem, null);
 
  return (
    <form>
      <input name="name" />
      <button formAction={saveAction}>Save</button>
      <button formAction={deleteAction}>Delete</button>
    </form>
  );
}

Using useFormStatus for a global loading indicator:

"use client";
 
import { useFormStatus } from "react-dom";
 
export function FormProgress() {
  const { pending, data } = useFormStatus();
 
  if (!pending) return null;
 
  return (
    <div className="fixed top-0 left-0 w-full h-1 bg-blue-500 animate-pulse" />
  );
}
 
// Use inside any form
<form action={myAction}>
  <FormProgress />
  {/* ... fields ... */}
</form>

Reset form after success:

"use client";
 
import { useActionState, useRef, useEffect } from "react";
 
export default function ResetableForm() {
  const [state, formAction, isPending] = useActionState(submitData, { success: false });
  const formRef = useRef<HTMLFormElement>(null);
 
  useEffect(() => {
    if (state.success) {
      formRef.current?.reset();
    }
  }, [state]);
 
  return (
    <form ref={formRef} action={formAction}>
      <input name="item" required />
      <button type="submit" disabled={isPending}>Add</button>
    </form>
  );
}

TypeScript Notes

  • useActionState is generic: useActionState<State>(fn: (prev: State, formData: FormData) => Promise<State>, initial: State).
  • useFormStatus returns { pending: boolean; data: FormData | null; method: string; action: string | ((formData: FormData) => void) | null }.
  • The action function signature is (previousState: State, formData: FormData) => State | Promise<State>.
  • The permalink parameter in useActionState is typed as string | undefined.

Gotchas

  • useFormStatus must be inside a <form> descendant -- Calling it in the same component that renders the <form> tag returns { pending: false } always. Fix: Extract the submit button into a child component.
  • Action return value replaces state entirely -- Unlike a reducer, the return value is the whole new state, not a partial update. Fix: Spread previous state if you want to merge: return { ...prev, error: "..." }.
  • Form is not automatically reset -- Unlike native form submissions, React form actions do not reset the form on success. Fix: Use a useRef on the form and call formRef.current?.reset() in an effect.
  • useActionState import location -- It is imported from "react", not "react-dom". The old useFormState was in "react-dom" and is now deprecated. Fix: import { useActionState } from "react".
  • Uncontrolled inputs after action -- If your action returns errors but you use uncontrolled inputs, the user's values are preserved in the DOM. If you use controlled inputs, you must restore them from state. Fix: Prefer uncontrolled inputs with form actions, or sync state from formData.
  • Multiple forms on the same page -- Each useActionState call is independent. Submitting one form does not affect another. Fix: This is expected behavior but be aware that useFormStatus only reflects the nearest ancestor form.

Alternatives

ApproachWhen to choose
Form Actions + useActionStateStandard React 19 forms with built-in pending state
react-hook-formComplex client-side validation, field arrays, watch behavior
FormikLegacy projects already using Formik
Manual onSubmit + fetchFull control over request lifecycle, custom headers
Server action only (no client state)Simple mutations that redirect after completion
ConformProgressive enhancement with Zod integration for server actions

FAQs

What is the difference between passing a function to form action vs using onSubmit?
  • <form action={fn}> lets React manage the pending state, error handling, and optimistic updates automatically
  • The traditional onSubmit + preventDefault + useState pattern requires manual state management
  • Form actions also enable progressive enhancement when paired with server actions
What does useActionState return and how do you use it?
  • It returns [state, wrappedAction, isPending]
  • state is updated each time the action completes, based on what the action returns
  • wrappedAction is passed to <form action={}> and isPending indicates if the action is currently executing
Where is useFormStatus imported from and how does it differ from useActionState's isPending?
  • useFormStatus is imported from "react-dom", while useActionState is from "react"
  • useFormStatus must be called from a component rendered inside a <form> (a descendant)
  • useActionState's isPending is available in the same component that renders the form
How do you display field-level validation errors with form actions?

Return an errors object from the action and render it per field:

async function submit(_prev: State, formData: FormData) {
  const errors: Record<string, string> = {};
  if (!formData.get("name")) errors.name = "Required";
  if (Object.keys(errors).length) return { errors };
  // ... save data
  return { errors: {} };
}
How do you reset a form after a successful submission?
const formRef = useRef<HTMLFormElement>(null);
const [state, formAction] = useActionState(submit, initial);
 
useEffect(() => {
  if (state.success) formRef.current?.reset();
}, [state]);
 
<form ref={formRef} action={formAction}>...</form>

React form actions do not reset the form automatically unlike native form submissions.

Can you have multiple submit buttons with different actions on the same form?

Yes. Use the formAction attribute on individual buttons:

<form>
  <input name="name" />
  <button formAction={saveAction}>Save</button>
  <button formAction={deleteAction}>Delete</button>
</form>
What is the permalink parameter in useActionState?
  • It is an optional string URL that enables progressive enhancement for server actions
  • When JavaScript has not loaded yet, the form submits to this URL and the server action executes
  • After completion, the browser redirects to the permalink
Gotcha: Why does useFormStatus always return pending: false in my component?
  • useFormStatus must be called from a descendant of the <form> element, not the same component that renders it
  • Extract the submit button into a separate child component that calls useFormStatus
Gotcha: The action return value replaces state entirely instead of merging. How do I handle partial updates?
  • Unlike a reducer, the return value is the whole new state, not a partial update
  • Spread the previous state if you want to merge: return { ...prev, error: "..." }
  • Design your state shape to be fully returned from every action path
How do you type the useActionState hook in TypeScript?
useActionState<State>(
  fn: (prev: State, formData: FormData) => Promise<State>,
  initial: State
): [State, (formData: FormData) => void, boolean]

The action function signature is (previousState: State, formData: FormData) => State | Promise<State>.

How is useFormStatus typed in TypeScript?
  • Returns { pending: boolean; data: FormData | null; method: string; action: string | ((formData: FormData) => void) | null }
  • data is the FormData being submitted (or null when not pending)
  • method reflects the HTTP method of the form
Should I use controlled or uncontrolled inputs with form actions?
  • Prefer uncontrolled inputs with form actions -- user values are preserved in the DOM between submissions
  • If using controlled inputs, you must restore their values from state when the action returns errors
  • Uncontrolled inputs reduce boilerplate and work naturally with FormData