Server Actions in React 19 simplify backend mutations by allowing client components to invoke server-side functions directly. However, handling runtime exceptions, field-level validation errors, and asynchronous loading states without degrading user experience requires a structured error-handling pattern.
In React 19, throwing raw errors inside Server Actions causes uncaught promise rejections on the client. This guide covers how to return type-safe state objects using the new useActionState hook alongside Zod validation schema models.
Understanding the Shift: React 18 useFormState vs. React 19 useActionState
In React 19, the useFormState hook from Next.js 14 was replaced by useActionState, which is now natively part of the React core library. It tracks the returned state of an async action, execution pending status, and initial form state in a single call.
| Feature | React 18 / Next.js 14 Pattern | React 19 Standard Pattern |
|---|---|---|
| Primary Hook | useFormState (from react-dom) |
useActionState (from react) |
| Pending State Tracking | Required useFormStatus child component |
Directly provided by [state, formAction, isPending] tuple |
| Validation Approach | Custom error object returns | Type-safe Discriminated Unions + Zod schema validation |
1. Creating a Type-Safe Server Action with Zod
Instead of throwing errors when validation fails, capture errors and return a structured response object containing field-level error messages.
// app/actions/user-actions.ts
'use server';
import { z } from 'zod';
// Define input schema
const UserSchema = z.object({
email: z.string().email({ message: 'Invalid email address' }),
username: z.string().min(3, { message: 'Username must be at least 3 characters' }),
});
// Define return state shape
export type FormState = {
success: boolean;
errors?: {
email?: string[];
username?: string[];
};
message?: string;
};
export async function createUserAction(
prevState: FormState,
formData: FormData
): Promise<FormState> {
// Extract data from form
const rawData = {
email: formData.get('email'),
username: formData.get('username'),
};
// Validate fields
const validatedFields = UserSchema.safeParse(rawData);
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
message: 'Validation failed. Please check input fields.',
};
}
try {
// Perform database insertion logic
await saveUserToDatabase(validatedFields.data);
return {
success: true,
message: 'User registered successfully!',
};
} catch (error) {
return {
success: false,
message: 'Database error: Could not save user profile.',
};
}
}
2. Consuming Server Actions with useActionState in Client Components
Wire the action response to your UI layer to render inline validation messages and handle submit pending states automatically.
// app/components/RegistrationForm.tsx
'use client';
import { useActionState } from 'react';
import { createUserAction, FormState } from '@/app/actions/user-actions';
const initialState: FormState = {
success: false,
};
export default function RegistrationForm() {
const [state, formAction, isPending] = useActionState(
createUserAction,
initialState
);
return (
<form action={formAction} className="space-y-4 max-w-md">
<div>
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input
type="email"
id="email"
name="email"
disabled={isPending}
className="w-full border p-2 rounded"
/>
{state.errors?.email && (
<p className="text-red-500 text-sm mt-1">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium">Username</label>
<input
type="text"
id="username"
name="username"
disabled={isPending}
className="w-full border p-2 rounded"
/>
{state.errors?.username && (
<p className="text-red-500 text-sm mt-1">{state.errors.username[0]}</p>
)}
</div>
{state.message && (
<div className={`p-3 rounded text-sm ${state.success ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{state.message}
</div>
)}
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{isPending ? 'Registering User...' : 'Submit Profile'}
</button>
</form>
);
}
Production Best Practices Checklist
- Never Throw Uncaught Errors in Actions: Always wrap mutations in
try/catchblocks and return structured objects so client components handle runtime states gracefully. - Sanitize Server Message Leaks: Avoid returning raw database error messages (like PostgreSQL constraint errors) directly to the user interface to prevent security leaks.
- Always Validate Input On Server: Client-side HTML form validation can be bypassed easily. Always validate incoming
FormDataon the server with Zod before running database operations.