Skip to main content

useFormState Hook: Server-Driven Form Updates

The useFormState hook couples a form's submission to a Server Action, automatically managing form state, validation errors, and success responses. Before React 19, form state lived in scattered useState calls: one for the email, one for errors, one for success. The useFormState hook consolidates this into a single reducer-like interface where the server drives the state updates and the UI reflects them automatically.

This hook transforms form handling from client-side orchestration (manage multiple states, validate, show errors) to declarative state management where the server function returns the next state shape. It's particularly powerful because validation can happen on the server, errors come back with the same form data, and the UI updates in a single round-trip.

How useFormState Works

useFormState wraps a Server Action and returns the current state and a form action. When the form submits, the action is called with form data, the server returns a new state, and the component re-renders with that state:

import { useFormState } from 'react-dom';
import { createUser } from './actions';

export function SignupForm() {
const initialState = { errors: null, success: false };
const [state, formAction] = useFormState(createUser, initialState);

return (
<form action={formAction}>
<div className="form-field">
<label>Email</label>
<input name="email" type="email" />
{state.errors?.email && (
<span className="error">{state.errors.email}</span>
)}
</div>

<div className="form-field">
<label>Password</label>
<input name="password" type="password" />
{state.errors?.password && (
<span className="error">{state.errors.password}</span>
)}
</div>

{state.success && <p className="success">Account created!</p>}

<button type="submit">Create Account</button>
</form>
);
}

The Server Action (createUser) receives the form data, validates it, and returns the next state (either with errors or a success flag). The form automatically displays these errors without the need for manual error handling.

Server Action Example

Here's what the Server Action looks like on the backend:

// actions.js (Server)
'use server';

import { db } from './db';
import { validateEmail, validatePassword } from './validation';

export async function createUser(previousState, formData) {
const email = formData.get('email');
const password = formData.get('password');

// Validate
const errors = {};

if (!validateEmail(email)) {
errors.email = 'Invalid email address';
}

if (!validatePassword(password)) {
errors.password = 'Password must be at least 8 characters';
}

// If validation failed, return errors
if (Object.keys(errors).length > 0) {
return { errors, success: false };
}

// Try to create the user
try {
const user = await db.users.create({ email, password });
return { errors: null, success: true, userId: user.id };
} catch (error) {
if (error.code === 'UNIQUE_CONSTRAINT') {
return {
errors: { email: 'Email already in use' },
success: false,
};
}
return {
errors: { general: 'An unexpected error occurred' },
success: false,
};
}
}

The action receives previousState (the last returned state) and formData (the submitted form fields). It validates, performs the operation, and returns the next state.

Building a Complete Form with Validation

Here's a realistic profile editor that uses useFormState for validation, editing, and success feedback:

import { useFormState } from 'react-dom';
import { updateProfile } from './actions';

function FormField({ name, label, type = 'text', error, defaultValue }) {
return (
<div className="form-field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
defaultValue={defaultValue}
className={error ? 'input error' : 'input'}
/>
{error && <span className="error-message">{error}</span>}
</div>
);
}

export function ProfileEditor({ user }) {
const initialState = { errors: {}, success: false };
const [state, formAction] = useFormState(updateProfile, initialState);

return (
<form action={formAction} className="profile-form">
<h2>Edit Profile</h2>

<FormField
name="name"
label="Full Name"
defaultValue={user.name}
error={state.errors?.name}
/>

<FormField
name="email"
label="Email Address"
type="email"
defaultValue={user.email}
error={state.errors?.email}
/>

<FormField
name="bio"
label="Bio"
defaultValue={user.bio}
error={state.errors?.bio}
/>

<button type="submit" className="btn-primary">
Save Changes
</button>

{state.success && (
<div className="alert alert-success">
Profile updated successfully!
</div>
)}

{state.errors?.general && (
<div className="alert alert-error">
{state.errors.general}
</div>
)}
</form>
);
}

The FormField component is reusable across forms. It receives the error for its field and displays it automatically. The form action handles validation, and the UI responds by showing errors inline.

Handling Async Operations with Transitions

You can pair useFormState with useTransition to track form submission state and show loading indicators:

import { useFormState } from 'react-dom';
import { useTransition } from 'react';
import { submitPayment } from './actions';

function SubmitButton() {
const { pending } = useTransition();

return (
<button type="submit" disabled={pending} className="btn-primary">
{pending ? 'Processing Payment...' : 'Pay Now'}
</button>
);
}

export function CheckoutForm({ orderId }) {
const initialState = { errors: null, success: false };
const [state, formAction] = useFormState(submitPayment, initialState);

return (
<form action={formAction}>
<div className="form-field">
<label>Card Number</label>
<input name="cardNumber" placeholder="1234 5678 9012 3456" />
{state.errors?.cardNumber && (
<span className="error">{state.errors.cardNumber}</span>
)}
</div>

<div className="form-row">
<div className="form-field">
<label>Expiry</label>
<input name="expiry" placeholder="MM/YY" />
</div>
<div className="form-field">
<label>CVV</label>
<input name="cvv" placeholder="123" />
</div>
</div>

<SubmitButton />

{state.success && (
<div className="alert alert-success">
Payment processed successfully! Order: {state.orderId}
</div>
)}
</form>
);
}

In this example, useTransition provides a pending state while useFormState handles the form's state and validation errors. Together they cover the full form lifecycle.

useFormState vs. Manual useState

AspectManual useStateuseFormState
State managementMultiple useState calls (email, password, errors, etc.)Single state object updated by server
ValidationClient-side (often duplicated from server)Server-side (single source of truth)
Error handlingManual—show/hide errors with separate stateAutomatic—errors returned with state
Re-submissionMust manually clear errors and show successAutomatic—server returns success flag
Lines of code25–40 per form8–15 per form
Server syncManual—fetch, then setStateAutomatic—Server Action returns state

Common Patterns

Pattern 1: Async validation with debounce

The Server Action can validate fields as the user types if you're using optimistic updates:

async function validateEmail(previousState, formData) {
const email = formData.get('email');

const exists = await db.users.findOne({ email });
if (exists) {
return { errors: { email: 'Email already registered' } };
}

return { errors: null };
}

Pattern 2: Multi-step forms

Use previousState to accumulate form data across multiple submissions:

export async function handleStep(previousState, formData) {
const step = previousState.step + 1;
const data = { ...previousState.data, ...Object.fromEntries(formData) };

if (step === 3) {
// Final submission
await db.saveApplication(data);
return { step, data, success: true };
}

return { step, data, success: false };
}

Pattern 3: Conditional fields based on state

export function ApplicationForm() {
const [state, formAction] = useFormState(handleStep, {
step: 1,
data: {},
});

return (
<form action={formAction}>
{state.step === 1 && (
<>
<input name="firstName" />
<input name="lastName" />
</>
)}

{state.step === 2 && (
<>
<input name="company" />
<input name="jobTitle" />
</>
)}

<button type="submit">
{state.step === 2 ? 'Submit' : 'Next'}
</button>
</form>
);
}

Key Takeaways

  • useFormState replaces scattered useState calls by unifying form state around a Server Action.
  • Validation happens on the server; errors come back in the returned state and display automatically.
  • The hook works seamlessly with useTransition to show loading states.
  • Use it for any form that needs server-side validation or complex state management.
  • The initial state shape is your responsibility; the Server Action returns updates to it.

Frequently Asked Questions

What's the difference between useFormState and useFormStatus?

useFormStatus reads the pending state of a form (whether it's currently submitting). useFormState couples a form to a Server Action and manages the form's data and error state. Use both together: useFormState for state and validation, useFormStatus for loading indicators.

Can I use useFormState with client-side validation?

You can, but it's not the best pattern. useFormState is designed for server validation. If you want client-side validation, validate before calling the Server Action or use a library like React Hook Form alongside useFormState.

What happens if the Server Action throws an error?

The error propagates, and the form's submission fails. The component should catch it with an error boundary or useTransition. The useFormState state does not update automatically on thrown errors—only on returned state.

Can I use useFormState with multiple forms on one page?

Yes. Each form gets its own useFormState hook with its own Server Action and state. They don't interfere with each other.

How do I reset the form after successful submission?

Return a new state from the Server Action and use a useRef to call form.reset() when state.success becomes true. Alternatively, redirect the user after success, and they won't see the old form.

Further Reading