Skip to content

Modern React 14: Forms with Actions

This is part 14 of the Modern React development series.

React 19 treats forms as first-class mutation surfaces. A form can call an Action, expose pending state, return result state, and support progressive enhancement when the framework integrates server features.

Concept

An Action is a function invoked by a form or a Transition. useActionState connects an Action to returned state and pending state. useFormStatus lets a nested submit component read the pending state of its parent form.

Terms

  • Action: A function React treats as an ordered unit of work from a form or Transition.
  • FormData: The browser object that carries submitted form field values.
  • Pending state: Whether a form or Action submission is still running.
  • Progressive enhancement: A path where a form can still submit before the client JavaScript is fully ready, when the framework supports it.

Mental model

Think of the form as a conveyor belt. The browser gathers fields into FormData, the Action processes them, and React brings back a result plus pending status for the UI.

How it is used

Use form Actions for profile edits, settings forms, contact forms, checkout steps, and server-backed mutations where the submit event is the natural boundary for collecting input and returning validation or success state.

How to use it

  1. Write an Action that accepts previous state and FormData when using useActionState.
  2. Return a serializable state object with success, field errors, or a message.
  3. Pass the returned form action to the form’s action prop.
  4. Render pending feedback with the third value from useActionState or useFormStatus in a child component.
  5. Keep field names stable because FormData reads by name.

Example: Profile form state

import { useActionState } from "react";
import { updateProfile } from "./profileApi";
type ProfileState = {
message: string;
};
async function saveProfile(
previousState: ProfileState,
formData: FormData,
): Promise<ProfileState> {
const displayName = String(formData.get("displayName") ?? "").trim();
if (displayName.length < 2) {
return { message: "Display name needs at least two characters." };
}
await updateProfile({ displayName });
return { message: "Profile saved." };
}
export function ProfileForm() {
const [state, formAction, isPending] = useActionState(saveProfile, {
message: "",
});
return (
<form action={formAction}>
<label>
Display name
<input name="displayName" />
</label>
<button disabled={isPending}>Save</button>
<p>{state.message}</p>
</form>
);
}
React output

The Action receives form data, returns display state, and lets the button reflect pending status.

Example: Nested submit button with useFormStatus

import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Saving..." : "Save"}</button>;
}
export function SettingsForm({ action }: { action: (data: FormData) => void }) {
return (
<form action={action}>
<input name="timezone" />
<SubmitButton />
</form>
);
}
React output

useFormStatus reads the parent form, so the submit button can stay reusable without receiving pending props.

Details to watch

  • Form names: Every submitted field needs a name for FormData to include it.
  • Hook placement: useFormStatus reads a parent form, not a form returned by the same component.
  • Serializability: Server-backed Actions need serializable input and output values.
  • Side effects: useActionState reducer actions may be async and perform side effects, unlike useReducer reducers.

Series navigation

References