Managing FormData & Complex State in React
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:
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
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.
'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:
// 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:
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 Type | Recommended Approach |
|---|---|
| Simple, few fields | Native FormData + useActionState |
| Client validation on blur | FormData + small local useState for errors |
| Complex: async validation, dependent fields, masking | TanStack Form or React Hook Form |
| Multi-step forms | useReducer + 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
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:
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+):
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:
// ✅ 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:
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:
- State that changes infrequently
- 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:
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:
- Non-linear navigation: going back to
searchfromresults, notloading - Optional steps: step 3 only appears under certain conditions
- Terminal states: no back button from
confirmation - 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:
// 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:
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
nextvalue 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
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:
- Model the state: define
BookingStateas a discriminated union (idle,searching,results,error) - Define actions:
SUBMIT,RECEIVED_RESULTS,BACK,ERROR - Build the reducer: pure function, no side effects, independently testable
- Create context + provider: wrap the page in
BookingProvider - Consume with
use(): replace prop drilling in child components - Derive booleans: replace
isSubmitting,isError,isSuccessflags with derived values fromstate.status - Optionally add type states: enforce via discriminated union that
flightOptionsonly exists whenstatus === '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
selectedFlightIdto the reducer state, store the ID not the flight object. Apply the same single source of truth principle from the anti-patterns section.