Managing Form Data with useActionState
Managing each form field with its own useState hook adds up fast. The native FormData object and React's useActionState give you a cleaner path -- less state, less boilerplate, and server-side validation that actually works.
Every React developer has written this form at some point.
function RegistrationForm() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [role, setRole] = useState('');
const [company, setCompany] = useState('');
const [agreed, setAgreed] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
await registerUser({ firstName, lastName, email, role, company, agreed });
}
// ...
}Six useState calls, six change handlers, six pieces of state to keep synchronized. And the thing is: the browser was already tracking all of it.
The DOM Stores Your Form State
When a user types into an <input>, that value lives in the DOM. Always. React's controlled pattern reads it back into JavaScript on every keystroke and stores it in state -- which then feeds back into the input's value prop.
You are duplicating state that already exists.
The native FormData API gives you direct access to what the form contains at submit time. No mirroring required.
function RegistrationForm() {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const payload = {
firstName: data.get('firstName') as string,
lastName: data.get('lastName') as string,
email: data.get('email') as string,
};
await registerUser(payload);
}
return (
<form onSubmit={handleSubmit}>
<input name="firstName" />
<input name="lastName" />
<input name="email" type="email" />
<button type="submit">Register</button>
</form>
);
}The name attribute on each input becomes the key in FormData. No onChange handlers, no state, no mirroring.
The form owns its data. You read it once at submit.
Adding Validation with Zod
FormData is iterable. Object.fromEntries(formData) turns it into a plain object, which Zod can parse directly.
import { z } from 'zod';
const registrationSchema = z.object({
firstName: z.string().min(2, 'First name must be at least 2 characters'),
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
role: z.string().min(1, 'Role is required'),
});
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const result = registrationSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
// { firstName: ['First name must be at least 2 characters'], ... }
setErrors(errors);
return;
}
await registerUser(result.data);
}You still need one piece of state here -- the validation errors. That is legitimate: validation messages are UI state, not form field state. The form field values themselves stay in the DOM.
useActionState: The Full Pattern
In a Next.js app, you can go one step further. Server Actions let you pass a function directly to a form's action prop. React's useActionState hook wraps that server action and gives you the response as state -- including loading state -- without any extra wiring.
// app/register/actions.ts
'use server';
import { z } from 'zod';
const registrationSchema = z.object({
firstName: z.string().min(2),
email: z.string().email(),
role: z.string().min(1),
});
export type RegistrationState =
| { status: 'idle' }
| { status: 'error'; errors: Record<string, string[]> }
| { status: 'success'; name: string };
export async function submitRegistration(
_prev: RegistrationState,
formData: FormData
): Promise<RegistrationState> {
const result = registrationSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { status: 'error', errors: result.error.flatten().fieldErrors };
}
await saveUserToDatabase(result.data);
return { status: 'success', name: result.data.firstName };
}// app/register/page.tsx
'use client';
import { useActionState } from 'react';
import { submitRegistration, type RegistrationState } from './actions';
const initialState: RegistrationState = { status: 'idle' };
export default function RegistrationForm() {
const [state, formAction, isPending] = useActionState(submitRegistration, initialState);
if (state.status === 'success') {
return <p>Welcome, {state.name}. Your registration is confirmed.</p>;
}
return (
<form action={formAction}>
<input name="firstName" required minLength={2} />
{state.status === 'error' && state.errors.firstName && (
<p className="error">{state.errors.firstName[0]}</p>
)}
<input name="email" type="email" required />
{state.status === 'error' && state.errors.email && (
<p className="error">{state.errors.email[0]}</p>
)}
<input name="role" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Registering...' : 'Register'}
</button>
</form>
);
}No isLoading useState. No isError useState. No onSubmit handler.
The state comes from useActionState as a discriminated union -- the same finite state pattern from the previous chapter. The server action is where the validation actually runs.
ExpandTwo-column comparison: left shows six useState calls and six onChange handlers for a controlled form, right shows zero useState plus FormData and a form action for an uncontrolled form -- both handle the same registration fields
Client-Side Validation on Blur
useActionState is server-round-trip validation. It fires on submit. If you need to validate a field the moment a user leaves it, you still need a small slice of local state.
Two approaches:
HTML5 built-in validation. The browser handles required, minLength, pattern, type="email" natively. Zero JavaScript needed. For most fields, this is enough.
<input name="email" type="email" required />Manual blur handler. When HTML5 is not enough, handle the blur event directly and keep a small errors object in state.
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
const { name, value } = e.target;
if (name === 'email' && !value.includes('@')) {
setFieldErrors(prev => ({ ...prev, email: 'Invalid email' }));
} else {
setFieldErrors(prev => ({ ...prev, [name]: '' }));
}
}This is a case where useState is correct. The validation errors are UI state that changes with user interaction and affects what renders. The form field values are still uncontrolled -- you are only tracking the error messages.
When to Reach for a Library
FormData and useActionState handle the common case well: a form with straightforward fields, a server action, and field-level error display.
They are not the right tool for:
- Async validation -- checking whether a username is taken while the user types
- Dependent fields -- showing a second dropdown whose options depend on the first
- Masking -- phone number formatting, credit card grouping
- Nested and repeating fields -- arrays of address objects, dynamic row addition
For these cases, reach for TanStack Form or React Hook Form. Both have extensive documentation, broad community support, and patterns for every edge case. Rolling your own solution for complex forms means reinventing a lot of hard-won API design.
The rule: if you can solve it with name attributes and a server action, do that. If the form's logic starts to require state that lives outside the server round-trip, a library will pay off.
The patterns in this post remove the accidental complexity from simple forms. For state that genuinely needs to span multiple components -- like a multi-step flow -- useReducer and Context gives you a lightweight global store without reaching for Redux.
The Essentials
- Controlled forms duplicate state that already exists. The DOM tracks every input value.
new FormData(e.currentTarget)reads all of it at submit time using thenameattribute. NouseStateper field needed. useActionStatewires a server action to form submission with zero boilerplate. You get the response as typed state, aisPendingboolean, and aformActionto pass to theform'sactionprop. The finite state pattern applies here too -- return a discriminated union from your server action.- Client-side validation on blur still uses
useState-- but only for the error messages, not the field values. HTML5 built-in validation handles most cases. For complex forms, reach for TanStack Form or React Hook Form.
Further Reading and Watching
- useActionState -- React Docs: The official reference for the hook, including how it interacts with server actions and progressive enhancement.
- Server Actions and Mutations -- Next.js Docs: How server actions work in the App Router, including the
actionprop on forms and howuseActionStateintegrates with them. - React 19 New Features -- Theo (t3.gg): A walkthrough of useActionState and the new form-related APIs introduced in React 19. Note: verify this YouTube link before publishing.
Keep reading