React 19 Server Actions Error Handling: How to Handle Validation and Form State

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/catch blocks 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 FormData on the server with Zod before running database operations.
Advertisement
Advertisement