Handling forms on the web used to mean wiring up local state for every single input, writing custom change handlers, and manually syncing error messages from your API responses. That model creates entirely too much boilerplate. With server actions, you submit form data directly to an asynchronous function executed on the server. You still need to deal with loading indicators and validation errors though, and that's where most implementations fall apart once nested objects enter the picture.
You can't treat a nested form structure like a flat key-value pair. When your server validation fails on a nested field, your error payload reflects that hierarchy. You have to map those nested errors back to the correct inputs in your component tree without losing user input or causing erratic layout shifts. Let's build a form that handles server-side validation and pending states correctly using built-in hooks.
Most developers reach for third-party form libraries the moment things get complicated. That's usually a mistake because it adds a massive abstraction layer over native browser features. You don't need a heavy library just to manage form submission lifecycles. React provides everything required right out of the box. You just have to wire it together properly.
Setting Up the Server Action and Validation Schema
Before touching any UI components, you need a server-side handler that processes the incoming form data and returns a structured response. We'll use a typical schema validation library to validate the payload. If validation fails, the action returns the field errors and the previous form data so the inputs don't clear out.
Here's what the server action looks like in practice. It parses the incoming FormData, runs it through a validation check, and returns an error dictionary if something's invalid.
import { z } from 'zod';
const profileSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
settings: z.object({
theme: z.enum(['light', 'dark']),
notifications: z.boolean(),
}),
});
export type FormState = {
success: boolean;
message: string;
fields?: Record<string, string>;
errors?: {
name?: string[];
settings?: {
theme?: string[];
notifications?: string[];
};
};
};
export async function updateProfile(prevState: FormState, formData: FormData): Promise<FormState> {
const rawData = {
name: formData.get('name'),
settings: {
theme: formData.get('theme'),
notifications: formData.get('notifications') === 'on',
},
};
const parsed = profileSchema.safeParse(rawData);
if (!parsed.success) {
return {
success: false,
message: 'Validation failed',
errors: parsed.error.flatten().fieldErrors,
};
}
return {
success: true,
message: 'Profile updated successfully',
};
}
Notice how the return type accommodates nested errors. The flatten().fieldErrors method gives you a clean dictionary of errors, but nested objects require careful handling in your components. If you want to review component architecture before building this out, check out React State Colocation: Where State Should Live to keep your data organized.
Server actions run securely on the server, meaning your validation schemas and database queries never leak to the client bundle. That architectural boundary keeps your application secure by default. You don't have to duplicate your validation logic across API endpoints and client components anymore.
Managing Pending State and Resetting Inputs
The useActionState hook handles the asynchronous lifecycle of your server action. It tracks the return value of your action and provides a boolean indicator for when the mutation's in flight. You should avoid creating manual loading states with standard event handlers when this hook handles the pending transition for you.
Here's how you wire up the component to consume the action state and display error feedback alongside loading indicators.
'use client';
import { useActionState } from 'react';
import { updateProfile, FormState } from './actions';
const initialState: FormState = {
success: false,
message: '',
};
export function ProfileForm() {
const [state, formAction, isPending] = useActionState(updateProfile, initialState);
return (
<form action={formAction}>
<div>
<label htmlFor="name">Name</label>
<input type="text" id="name" name="name" defaultValue="Jane Doe" />
{state.errors?.name && <p className="error">{state.errors.name[0]}</p>}
</div>
<div>
<label htmlFor="theme">Theme</label>
<select id="theme" name="theme" defaultValue="dark">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
{state.errors?.settings?.theme && (
<p className="error">{state.errors.settings.theme[0]}</p>
)}
</div>
<div>
<label>
<input type="checkbox" name="notifications" defaultChecked />
Enable Notifications
</label>
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save Changes'}
</button>
{state.message && (
<p className={state.success ? 'success' : 'error'}>{state.message}</p>
)}
</form>
);
}
The gotcha here's how default values interact with server action states. If you rely solely on uncontrolled inputs, the form resets its uncontrolled state when the component remounts, but keeping user input alive during failed validation attempts requires passing defaultValue props derived from the previous form submission. If your components grow complex and you find yourself needing to handle complex asynchronous testing requirements, take a look at Testing React Components: Role Queries & Async State to ensure your assertions wait for pending transitions to finish.
Don't forget to disable your submit button during the pending state. Users love clicking submit multiple times if the server takes a fraction of a second to respond. Disabling the button stops duplicate submissions right at the source.
Handling Deeply Nested Structures
As your forms scale, flat schemas stop working. You end up with arrays of objects, dynamic field generators, and multi-step configurations. When an error occurs deep inside an array of items, mapping the index back to the UI element requires explicit indexing in your input names.
Name your inputs using dot notation or bracket notation, such as items[0].title, so your server action can parse them into nested objects without manual transformation. If you parse them as raw entries, your validation library needs to match that exact shape. Keep your error lookup logic centralized in a small helper function to prevent your JSX from turning into a mess of optional chaining operators.
Debugging form state issues can test your patience if you don't log your incoming payload on the server. Always inspect the raw FormData entries before passing them into your validation library. You'll save hours of guessing why a specific checkbox or nested select menu's dropping its value during the mutation lifecycle.












