← All posts

React 19 Form Actions with FastAPI: server validation without duplication

useActionState + Pydantic: one schema, field-level errors, built-in isPending — roughly half the form code.

Contents

In brief

The classic form stack — Pydantic on the backend, Zod on the frontend, manual useState and useTransition. The CitizenApp author pairs React 19 Form Actions with FastAPI: validation rules live once on the server; the client receives structured errors via useActionState.

What happened

The "old way" spreads form logic across five places: Pydantic schema, Zod duplicate, REST endpoint, manual error messages, submission callbacks. A new validation rule touches at least three files; when the client passes but the server rejects, users see a spinner and a generic error.

The pattern: the form posts to a server action ('use server'), the action calls FastAPI, FastAPI validates the body with Pydantic and returns 422 details or success. useActionState yields [state, formAction, isPending] — loading and field errors without extra hooks.

The server schema is the single source of truth. TypeScript types can be generated from Pydantic (datamodel-code-generator). JSX filters errors by field and renders them beside inputs.

Why it matters

Duplicated validation is a top source of full-stack bugs. Server checks are mandatory (clients are bypassable), but client-side "UX validation" often diverges from the backend. Centralizing on Pydantic removes drift.

A server action bridges FastAPI without exposing it to the browser — sensitive operations stay server-side. isPending disables the submit button without useState. The author estimates roughly half the form code.

Gotcha: FastAPI 422 responses use a detail array with loc (tuple — field name at index 1) and msg. Map once to { field, message }.

In practice

  1. Define Pydantic request/response models with errors: list[ValidationError].
  2. Server action reads FormData, POSTs JSON to FastAPI, maps 422 to your format.
  3. Wire useActionState(action, null) — skip duplicate loading state.
  4. Render errors via state.errors.filter(e => e.field === 'email').
  5. Generate TS types from Python schemas — do not copy by hand.
  6. Custom messages via Field(description=...) in Pydantic.

Takeaway

React 19 Form Actions plus FastAPI escape the "two schemas, two message sets" trap. Not a replacement for instant client UX hints, but a solid submission foundation. Step-by-step code is in the original on Dev.to.