Managing FormData & Complex State in React

11 min read

Part 1: Managing Forms Without useState 🎯

Most React developers default to a useState for every form field. For simple forms, there's a better approach: the browser already stores form state for you.

The Native FormData API

When a user types into an <input name="firstName">, that data exists in the DOM. You don't need to mirror it in React state. On submit, read it directly:

TSX
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); const firstName = formData.get('firstName') as string; console.log(firstName); }

FormData is a native browser API — no library required. Every input with a name attribute is automatically captured.

Combining FormData with Zod Validation

TSX
import { z } from 'zod'; const formSchema = z.object({ firstName: z.string().min(1, 'First name is required'), email: z.string().email('Invalid email'), }); function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); const result = formSchema.safeParse(Object.fromEntries(formData)); if (!result.success) { console.error(result.error.flatten()); return; } console.log(result.data.firstName); // typed and validated }

useActionState for Next.js Forms

For forms in Next.js, useActionState connects a server action directly to form state handling submission, loading, and response in one hook.

TSX
'use client'; import { useActionState } from 'react'; import { submitTravelData } from './actions'; const initialState = { status: 'idle', errors: null, data: null }; export default function TravelForm() { const [state, formAction, isPending] = useActionState(submitTravelData, initialState); // Derive booleans from state — don't store them separately const isSuccess = state.status === 'success'; const errors = state.errors; return ( <form action={formAction}> <input name="firstName" required /> <input name="lastName" required /> {isPending && <p>Submitting...</p>} {errors?.firstName && <p>{errors.firstName}</p>} {isSuccess && <p>Booking confirmed!</p>} <button type="submit" disabled={isPending}>Submit</button> </form> ); }

The server action receives FormData, validates with Zod, and returns structured state:

TypeScript
// actions.ts 'use server'; export async function submitTravelData(prevState: FormState, formData: FormData) { const result = travelSchema.safeParse(Object.fromEntries(formData)); if (!result.success) { // Return raw formData so the user's inputs aren't wiped on error return { status: 'error', errors: result.error.flatten().fieldErrors, data: formData }; } return { status: 'success', errors: null, data: result.data }; }

Blur Validation on Uncontrolled Inputs

useActionState validates on submit only. For field-level validation on blur, a small local useState for validation errors is appropriate it's purely client-side UI state:

TSX
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({}); function handleBlur(e: React.FocusEvent<HTMLInputElement>) { const value = e.target.value; if (value.length < 3) { setValidationErrors(prev => ({ ...prev, firstName: 'Min 3 characters' })); } }

Also prefer HTML5 native validation (required, pattern, minLength) before adding JavaScript it's free and well-supported.

Form Complexity Guidelines

Form TypeRecommended Approach
Simple, few fieldsNative FormData + useActionState
Client validation on blurFormData + small local useState for errors
Complex: async validation, dependent fields, maskingTanStack Form or React Hook Form
Multi-step formsuseReducer + context (see Part 3)

Don't build your own form abstraction for complex cases. Forms have a large surface area of edge cases. Point teammates at documented, maintained libraries instead.


Part 2: useReducer for Complex State Logic 💡

useReducer is the right tool when state has multiple interdependent transitions that are difficult to express cleanly with scattered useState calls.

The Core Pattern

TSX
type BookingState = | { status: 'idle'; flightOptions: null; searchParams: null } | { status: 'searching'; flightOptions: null; searchParams: SearchParams } | { status: 'results'; flightOptions: Flight[]; searchParams: SearchParams } | { status: 'error'; flightOptions: null; searchParams: SearchParams }; type BookingAction = | { type: 'SUBMIT'; payload: SearchParams } | { type: 'RECEIVED_RESULTS'; payload: Flight[] } | { type: 'ERROR' } | { type: 'BACK' }; const initialState: BookingState = { status: 'idle', flightOptions: null, searchParams: null, }; function bookingReducer(state: BookingState, action: BookingAction): BookingState { switch (action.type) { case 'SUBMIT': return { status: 'searching', flightOptions: null, searchParams: action.payload }; case 'RECEIVED_RESULTS': return { ...state, status: 'results', flightOptions: action.payload }; case 'ERROR': return { ...state, status: 'error' }; case 'BACK': // Only navigate back from 'results' — encode this in the reducer, not the UI if (state.status === 'results') { return { ...state, status: 'idle', flightOptions: null }; } return state; default: return state; } }

The BACK case demonstrates a key advantage of reducers: the same action behaves differently depending on current state. Impossible to express cleanly with separate boolean flags.

Sharing State with Context

useReducer alone keeps state local. Combine it with React Context to share both state and dispatch across the tree without prop drilling:

TSX
type BookingContextValue = { state: BookingState; dispatch: (action: BookingAction) => void; }; const BookingContext = createContext<BookingContextValue>(null as unknown as BookingContextValue); function BookingProvider({ children }: { children: React.ReactNode }) { const [state, dispatch] = useReducer(bookingReducer, initialState); return ( <BookingContext value={{ state, dispatch }}> {children} </BookingContext> ); }

Consume with the use hook (React 19+):

TSX
import { use } from 'react'; function SearchForm() { const { dispatch } = use(BookingContext); return ( <button onClick={() => dispatch({ type: 'SUBMIT', payload: formData })}> Search Flights </button> ); } function SearchResults() { const { state, dispatch } = use(BookingContext); if (state.status !== 'results') return null; return ( <> {state.flightOptions.map(f => <div key={f.id}>{f.name}</div>)} <button onClick={() => dispatch({ type: 'BACK' })}>Back</button> </> ); }

Conditional Rendering with Finite States

Explicit finite states make conditional rendering unambiguous:

TSX
// ✅ Each status maps to exactly one view function BookingContent() { const { state } = use(BookingContext); if (state.status === 'idle') return <SearchForm />; if (state.status === 'searching') return <LoadingView />; if (state.status === 'results') return <ResultsView />; if (state.status === 'error') return <ErrorView />; } // ❌ Implicit — hard to reason about, easy to miss cases // if (isLoading && !results.length) ... // if (!isLoading && results.length > 0 && !isError) ...

Triggering Effects Based on State

Use a single useEffect keyed on state.status not on individual variables:

TSX
useEffect(() => { if (state.status !== 'searching') return; let cancelled = false; fetchFlights(state.searchParams) .then(results => { if (!cancelled) dispatch({ type: 'RECEIVED_RESULTS', payload: results }); }) .catch(() => { if (!cancelled) dispatch({ type: 'ERROR' }); }); return () => { cancelled = true; }; }, [state.status]);

This is declarative side effects in practice: the state declares what work should happen, not the change of a random variable.

Performance Note on Context

Context re-renders all consumers on every state update. Acceptable for:

  1. State that changes infrequently
  2. Small to medium component trees

Becomes a problem for high-frequency updates (mouse position, timers, live subscriptions). For those, use a dedicated state library covered in the next section.


Part 3: Step-Based vs. Graph-Based Multi-Step Flows 🎯

The Array of Steps Pattern (and Its Limits)

A common approach to multi-step forms:

TSX
const steps = ['search', 'loading', 'results', 'confirm']; const [stepIndex, setStepIndex] = useState(0); const nextStep = () => setStepIndex(i => i + 1); const prevStep = () => setStepIndex(i => i - 1);

This breaks down quickly when you need:

  1. Non-linear navigation: going back to search from results, not loading
  2. Optional steps: step 3 only appears under certain conditions
  3. Terminal states: no back button from confirmation
  4. Conditional branching: different next steps depending on prior choices

Each of these requirements becomes an if statement inside nextStep or prevStep logic completely disconnected from where the steps are defined.

The Directed Graph Approach

Replace the array with an object that explicitly encodes every valid transition:

TSX
// No library needed — plain JavaScript object const stepGraph = { search: { next: 'loading', back: null }, loading: { next: 'results', back: 'search' }, results: { next: 'confirm', back: 'search' }, // skips 'loading' on back confirm: { next: 'complete', back: 'results' }, complete: { next: null, back: null }, // terminal state } as const; type Step = keyof typeof stepGraph; const [currentStep, setCurrentStep] = useState<Step>('search'); const nextStep = () => { const next = stepGraph[currentStep].next; if (next) setCurrentStep(next); }; const prevStep = () => { const back = stepGraph[currentStep].back; if (back) setCurrentStep(back); };

"Can't go back from complete" is encoded in the data — back: null. "Going back from results skips loading" is also in the data back: 'search'. No if statements in navigation logic.

Visualized as a state diagram:

Plain text
search → loading → results → confirm → complete ↑________↑ ↑ (back to search) (back to results) [complete has no back]

What looks linear in an array is almost always a directed graph when you map the real transitions.

Optional steps are handled by dynamically choosing the next value based on conditions — the graph structure stays clean. With an array, you'd need index arithmetic and special-cased skips.


Exercise: Refactoring to useReducer + Context

📂 exercise-reducer/page.tsx

The exercise starts with a multi-step flight search form that passes props through multiple component levels, loses navigation state on back, and manages everything with scattered useState + useEffect.

Refactor steps:

  1. Model the state: define BookingState as a discriminated union (idle, searching, results, error)
  2. Define actions: SUBMIT, RECEIVED_RESULTS, BACK, ERROR
  3. Build the reducer: pure function, no side effects, independently testable
  4. Create context + provider: wrap the page in BookingProvider
  5. Consume with use() : replace prop drilling in child components
  6. Derive booleans: replace isSubmitting, isError, isSuccess flags with derived values from state.status
  7. Optionally add type states: enforce via discriminated union that flightOptions only exists when status === 'results'

Strangler Fig technique: Don't delete the old code first. Build the reducer and context alongside the existing implementation. Verify behavior matches with console.log(state), then remove old state variables one by one.

When adding selectedFlightId to the reducer state, store the ID not the flight object. Apply the same single source of truth principle from the anti-patterns section.