Form State, Validation and Optimistic UI

Intermediate
13 min

Form State, Validation and Optimistic UI

A form that only submits is not finished. Users need validation messages, a pending indicator, and ideally their change on screen before the server confirms it. React 19 ships three hooks that pair naturally with Server Actions: useActionState, useFormStatus and useOptimistic. After this lesson you will be able to validate input with Zod, return errors to the form, show pending states, and apply optimistic updates.

Returning State From an Action: useActionState

useActionState wraps an action so that whatever it returns becomes component state. The action now receives the previous state first, then the FormData.

tsx
// app/signup/SignupForm.tsx "use client"; import { useActionState } from "react"; import { signup, type SignupState } from "./actions"; const initial: SignupState = {}; export function SignupForm() { const [state, formAction, pending] = useActionState(signup, initial); return ( <form action={formAction}> <input name="email" type="email" /> {state.errors?.email && <p role="alert">{state.errors.email}</p>} <input name="password" type="password" /> {state.errors?.password && <p role="alert">{state.errors.password}</p>} <button disabled={pending}>{pending ? "Creating..." : "Sign up"}</button> </form> ); }

The hook returns the latest state, a wrapped action for the form, and a pending boolean. Because the state is plain data, the same pattern carries success messages or partially saved drafts.

Validating With Zod

Never trust FormData. The action in the sample at the top of this lesson uses a Zod schema and safeParse, which returns either { success: true, data } or { success: false, error }. Iterating error.issues and keying messages by field name produces a structure the form renders next to each input.

For unexpected failures (database down), catch the error inside the action and return { errors: { form: "Something went wrong" } } rather than throwing, which would trigger the nearest error.tsx.

Pending State in Child Components: useFormStatus

A reusable submit button rendered inside the form cannot see pending from useActionState. useFormStatus from react-dom reads the status of the nearest parent form:

tsx
"use client"; import { useFormStatus } from "react-dom"; export function SubmitButton({ label }: { label: string }) { const { pending } = useFormStatus(); return <button type="submit" disabled={pending} aria-busy={pending}>{pending ? "Saving..." : label}</button>; }

The hook only works in a component rendered inside the <form>.

Optimistic Updates: useOptimistic

For low-risk mutations such as adding a todo, waiting for the server feels slow. useOptimistic shows the expected result immediately and reconciles once the action completes:

tsx
"use client"; import { useOptimistic } from "react"; import { addTodo } from "./actions"; type Todo = { id: string; title: string }; export function TodoList({ todos }: { todos: Todo[] }) { const [optimistic, addOptimistic] = useOptimistic(todos, (current, title: string) => [ ...current, { id: "temp", title }, ]); async function action(formData: FormData) { addOptimistic(String(formData.get("title"))); await addTodo(formData); } return ( <> <form action={action}><input name="title" /><button>Add</button></form> <ul>{optimistic.map((t) => <li key={t.id}>{t.title}</li>)}</ul> </> ); }

When addTodo revalidates the page, the todos prop arrives with the real record and React discards the temporary entry. If the action throws, React reverts to the previous list.

Common mistakes

  • Forgetting the prevState parameter after switching to useActionState, so FormData arrives in the wrong position.
  • Calling useFormStatus in the component that renders the <form>; it must be a descendant.
  • Using optimistic updates for irreversible operations such as payments.
Quick Quiz
Question 1 of 3

What is the signature of an action used with `useActionState`?

Key Takeaways

  • useActionState returns [state, action, pending]; the action signature becomes (prevState, formData).
  • Validate on the server with Zod's safeParse and return field errors as plain data.
  • useFormStatus gives pending state to components rendered inside a form.
  • useOptimistic shows the expected result instantly and reverts on failure.
  • Return handled errors as state; throw only for failures that should hit error.tsx.

Next lesson: API Route Handlers — expose HTTP endpoints with route.ts for clients that are not your own pages.

Form State, Validation and Optimistic UI - Next.js | CodeYourCraft | CodeYourCraft