•15 min read

React 19 Actions in Practice: useActionState, useOptimistic & Server Action Resiliency

React 19 Actions in Practice: useActionState, useOptimistic & Server Action Resiliency

React 19 introduces a paradigm shift in handling data mutations and server interactions, primarily through Actions. This guide dissects useActionState, useOptimistic, and useFormStatus within the Next.js 15 App Router context, demonstrating their application in building resilient, user-centric forms. We will cover optimistic updates, error handling, multi-step workflows, and robust schema validation.

Audio Briefing
0:00 / 0:00

React Actions: A Unified Mutation Model

React Actions provide a standardized mechanism for components to interact with server-side data mutations. They abstract away the complexities of network requests, revalidation, and state management, allowing developers to focus on business logic. Actions can be invoked directly from <form> elements or programmatically via startTransition.

Core Hooks

  • useActionState: Manages the state and return value of a server action. It provides the last result of the action and a dispatch function to invoke it. Crucially, it handles pending states and re-renders upon action completion.
  • useOptimistic: Enables immediate UI updates in anticipation of a successful server action. It provides a temporary "optimistic" state that can be rolled back if the action fails, or confirmed if it succeeds.
  • useFormStatus: A client-side hook providing information about the pending state of the parent <form> submission. Useful for disabling buttons or showing loading indicators.
Advertisement

Architectural Overview: Server Components & Actions

Next.js 15 App Router leverages React Server Components (RSCs) and Server Actions to blur the lines between client and server. Actions are functions marked with 'use server' that execute exclusively on the server. They can be defined directly within Server Components or in separate files.

Data Flow with Actions

  1. Client Interaction: User submits a form or triggers an action.
  2. Action Invocation: The action function is called.
  3. Server Execution: The server action executes, performs database operations, and returns a result.
  4. Client Update: React re-renders components based on the action's result, potentially revalidating data caches.

Implementing a Production-Grade Form

Consider a user profile update form requiring validation, optimistic updates, and multi-step progression.

Schema Definition with Zod

Robust validation is critical. We'll use Zod for both client and server-side schema definition.

// lib/schemas.ts
import { z } from 'zod';

export const profileSchema = z.object({
  username: z.string().min(3, 'Username must be at least 3 characters.').max(50, 'Username cannot exceed 50 characters.'),
  email: z.string().email('Invalid email address.'),
  bio: z.string().max(200, 'Bio cannot exceed 200 characters.').optional(),
});

export const passwordSchema = z.object({
  currentPassword: z.string().min(8, 'Current password must be at least 8 characters.'),
  newPassword: z.string().min(8, 'New password must be at least 8 characters.'),
  confirmNewPassword: z.string(),
}).refine((data) => data.newPassword === data.confirmNewPassword, {
  message: 'New passwords do not match.',
  path: ['confirmNewPassword'],
});

export type ProfileFormState = {
  message: string;
  errors?: {
    username?: string[];
    email?: string[];
    bio?: string[];
    currentPassword?: string[];
    newPassword?: string[];
    confirmNewPassword?: string[];
  };
  fieldValues?: {
    username?: string;
    email?: string;
    bio?: string;
  };
};

Server Actions

These actions will handle the actual data mutations.

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { profileSchema, passwordSchema, ProfileFormState } from '@/lib/schemas';
import { z } from 'zod';

// Simulate a database
const usersDb = new Map<string, { username: string; email: string; bio?: string; passwordHash: string }>();
usersDb.set('user123', { username: 'user123', email: 'user@example.com', bio: 'Initial bio.', passwordHash: 'hashed_password_123' });

export async function updateProfile(
  prevState: ProfileFormState,
  formData: FormData
): Promise<ProfileFormState> {
  await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate network delay

  const rawFormData = {
    username: formData.get('username'),
    email: formData.get('email'),
    bio: formData.get('bio'),
  };

  const validatedFields = profileSchema.safeParse(rawFormData);

  if (!validatedFields.success) {
    const fieldErrors = validatedFields.error.flatten().fieldErrors;
    return {
      message: 'Validation failed.',
      errors: fieldErrors,
      fieldValues: rawFormData as any, // Return raw values for re-population
    };
  }

  const { username, email, bio } = validatedFields.data;

  // Simulate DB update
  const currentUser = usersDb.get('user123'); // In real app, get from session
  if (currentUser) {
    currentUser.username = username;
    currentUser.email = email;
    currentUser.bio = bio;
    usersDb.set('user123', currentUser);
  } else {
    return { message: 'User not found.', errors: {} };
  }

  revalidatePath('/profile'); // Invalidate cache for the profile page
  return { message: 'Profile updated successfully!', fieldValues: { username, email, bio } };
}

export async function changePassword(
  prevState: ProfileFormState,
  formData: FormData
): Promise<ProfileFormState> {
  await new Promise(resolve => setTimeout(resolve, 1500)); // Simulate network delay

  const rawFormData = {
    currentPassword: formData.get('currentPassword'),
    newPassword: formData.get('newPassword'),
    confirmNewPassword: formData.get('confirmNewPassword'),
  };

  const validatedFields = passwordSchema.safeParse(rawFormData);

  if (!validatedFields.success) {
    const fieldErrors = validatedFields.error.flatten().fieldErrors;
    return {
      message: 'Password validation failed.',
      errors: fieldErrors,
    };
  }

  const { currentPassword, newPassword } = validatedFields.data;

  // Simulate current password check
  const currentUser = usersDb.get('user123');
  if (!currentUser || currentUser.passwordHash !== 'hashed_password_123') { // In real app, hash and compare
    return { message: 'Incorrect current password.', errors: { currentPassword: ['Incorrect password.'] } };
  }

  // Simulate password update
  currentUser.passwordHash = `hashed_${newPassword}`; // In real app, hash new password
  usersDb.set('user123', currentUser);

  revalidatePath('/profile/security');
  return { message: 'Password updated successfully!' };
}

Client Component: Profile Form

This component uses useActionState, useOptimistic, and useFormStatus.

// app/profile/page.tsx
'use client';

import { useActionState, useOptimistic, useRef, useEffect, useState } from 'react';
import { useFormStatus } from 'react-dom';
import { updateProfile, changePassword } from '@/app/actions';
import { ProfileFormState } from '@/lib/schemas';

// Initial state for useActionState
const initialProfileState: ProfileFormState = {
  message: '',
  errors: {},
  fieldValues: { username: 'user123', email: 'user@example.com', bio: 'Initial bio.' },
};

const initialPasswordState: ProfileFormState = {
  message: '',
  errors: {},
};

function SubmitButton({ label }: { label: string }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" aria-disabled={pending} disabled={pending} className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50">
      {pending ? 'Saving...' : label}
    </button>
  );
}

export default function ProfileSettings() {
  const [profileState, profileAction] = useActionState(updateProfile, initialProfileState);
  const [passwordState, passwordAction] = useActionState(changePassword, initialPasswordState);

  // Optimistic state for profile updates
  const [optimisticProfile, addOptimisticProfile] = useOptimistic(
    profileState.fieldValues,
    (currentValues, newValues: Partial<typeof profileState.fieldValues>) => ({
      ...currentValues,
      ...newValues,
    })
  );

  const profileFormRef = useRef<HTMLFormElement>(null);
  const passwordFormRef = useRef<HTMLFormElement>(null);

  // Effect to reset form fields or show success messages
  useEffect(() => {
    if (profileState.message === 'Profile updated successfully!') {
      // Optionally clear form or show a toast
      console.log('Profile update success:', profileState.message);
    }
    if (passwordState.message === 'Password updated successfully!') {
      passwordFormRef.current?.reset(); // Clear password fields
      console.log('Password update success:', passwordState.message);
    }
  }, [profileState, passwordState]);

  const handleProfileSubmit = async (formData: FormData) => {
    // Optimistically update UI
    addOptimisticProfile({
      username: formData.get('username') as string,
      email: formData.get('email') as string,
      bio: formData.get('bio') as string,
    });
    await profileAction(formData);
  };

  const [currentStep, setCurrentStep] = useState(1);

  return (
    <div className="max-w-2xl mx-auto p-6 bg-white shadow-md rounded-lg">
      <h1 className="text-2xl font-bold mb-6">Profile Settings</h1>

      {/* Step 1: Profile Details */}
      {currentStep === 1 && (
        <section className="mb-8">
          <h2 className="text-xl font-semibold mb-4">Update Profile</h2>
          <form ref={profileFormRef} action={handleProfileSubmit} className="space-y-4">
            <div>
              <label htmlFor="username" className="block text-sm font-medium text-gray-700">Username</label>
              <input
                type="text"
                id="username"
                name="username"
                defaultValue={optimisticProfile?.username || ''}
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
              />
              {profileState.errors?.username && (
                <p className="text-red-500 text-sm mt-1">{profileState.errors.username[0]}</p>
              )}
            </div>
            <div>
              <label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
              <input
                type="email"
                id="email"
                name="email"
                defaultValue={optimisticProfile?.email || ''}
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
              />
              {profileState.errors?.email && (
                <p className="text-red-500 text-sm mt-1">{profileState.errors.email[0]}</p>
              )}
            </div>
            <div>
              <label htmlFor="bio" className="block text-sm font-medium text-gray-700">Bio</label>
              <textarea
                id="bio"
                name="bio"
                rows={3}
                defaultValue={optimisticProfile?.bio || ''}
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
              ></textarea>
              {profileState.errors?.bio && (
                <p className="text-red-500 text-sm mt-1">{profileState.errors.bio[0]}</p>
              )}
            </div>
            {profileState.message && profileState.message !== 'Validation failed.' && (
              <p className={`text-sm ${profileState.message.includes('successfully') ? 'text-green-600' : 'text-red-600'}`}>
                {profileState.message}
              </p>
            )}
            <div className="flex justify-between items-center">
              <SubmitButton label="Save Profile" />
              <button
                type="button"
                onClick={() => setCurrentStep(2)}
                className="text-blue-600 hover:underline"
              >
                Next: Change Password &rarr;
              </button>
            </div>
          </form>
        </section>
      )}

      {/* Step 2: Change Password */}
      {currentStep === 2 && (
        <section>
          <h2 className="text-xl font-semibold mb-4">Change Password</h2>
          <form ref={passwordFormRef} action={passwordAction} className="space-y-4">
            <div>
              <label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700">Current Password</label>
              <input
                type="password"
                id="currentPassword"
                name="currentPassword"
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
              />
              {passwordState.errors?.currentPassword && (
                <p className="text-red-500 text-sm mt-1">{passwordState.errors.currentPassword[0]}</p>
              )}
            </div>
            <div>
              <label htmlFor="newPassword" className="block text-sm font-medium text-gray-700">New Password</label>
              <input
                type="password"
                id="newPassword"
                name="newPassword"
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
              />
              {passwordState.errors?.newPassword && (
                <p className="text-red-500 text-sm mt-1">{passwordState.errors.newPassword[0]}</p>
              )}
            </div>
            <div>
              <label htmlFor="confirmNewPassword" className="block text-sm font-medium text-gray-700">Confirm New Password</label>
              <input
                type="password"
                id="confirmNewPassword"
                name="confirmNewPassword"
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
              />
              {passwordState.errors?.confirmNewPassword && (
                <p className="text-red-500 text-sm mt-1">{passwordState.errors.confirmNewPassword[0]}</p>
              )}
            </div>
            {passwordState.message && passwordState.message !== 'Password validation failed.' && (
              <p className={`text-sm ${passwordState.message.includes('successfully') ? 'text-green-600' : 'text-red-600'}`}>
                {passwordState.message}
              </p>
            )}
            <div className="flex justify-between items-center">
              <button
                type="button"
                onClick={() => setCurrentStep(1)}
                className="text-blue-600 hover:underline"
              >
                &larr; Previous: Profile Details
              </button>
              <SubmitButton label="Change Password" />
            </div>
          </form>
        </section>
      )}
    </div>
  );
}

Explanation of Components

  • ProfileSettings: This is a client component ('use client').
    • useActionState(updateProfile, initialProfileState): Initializes the state for the profile update action. profileState holds the last result (message, errors, field values), and profileAction is the dispatch function.
    • useOptimistic(profileState.fieldValues, ...): Creates optimisticProfile. When addOptimisticProfile is called, optimisticProfile immediately updates. If profileAction fails, optimisticProfile reverts to profileState.fieldValues. If it succeeds, profileState.fieldValues updates, and optimisticProfile naturally reflects the new confirmed state.
    • useFormStatus(): Used within SubmitButton to disable the button during form submission, preventing double submissions.
    • useEffect hook: Clears password fields on successful password change and logs messages.
    • Multi-step form: currentStep state manages which section of the form is visible.
    • Error display: profileState.errors and passwordState.errors are rendered below their respective input fields.
    • defaultValue: Crucially, defaultValue is used for inputs to allow React to manage the input state effectively with useOptimistic and useActionState's returned fieldValues.

Architectural Comparison: Actions vs. Traditional API Calls

FeatureReact Actions (Next.js App Router)Traditional REST/GraphQL (Client-side Fetch)
Data FlowDirect function call (RPC-like) from client to server.HTTP request (GET/POST/PUT/DELETE) from client to API endpoint.
ValidationServer-side validation (e.g., Zod) directly in action.Server-side validation in API handler. Client-side validation often duplicated.
State MgmtuseActionState, useOptimistic, useFormStatus for mutation state.useState, useReducer, React Query/SWR for mutation state.
RevalidationrevalidatePath, revalidateTag for automatic cache invalidation.Manual cache invalidation (e.g., queryClient.invalidateQueries).
Optimistic UIBuilt-in useOptimistic hook.Manual implementation with useState and rollback logic.
Error HandlingAction returns state object with errors.Catch block on fetch or library-specific error handling.
Network LayerAbstracted by React/Next.js.Explicit fetch or Axios calls.
Bundle SizePotentially smaller client bundle as server logic is removed.Client bundle includes data fetching and state management libraries.
ComplexityInitial learning curve for new mental model.Familiar HTTP request model.
Advertisement

Production Gotchas & Troubleshooting

  1. "Actions must be functions" Error:

    • Symptom: TypeError: Actions must be functions. Received: [object Object] or similar.
    • Cause: Forgetting 'use server' at the top of your action file or function. Or, attempting to pass a non-function as an action prop to a <form>.
    • Fix: Ensure all server actions are correctly marked with 'use server'. Verify the action prop on your <form> is indeed a function.
  2. revalidatePath / revalidateTag Not Working:

    • Symptom: Data on the page doesn't update after a successful action, even with revalidatePath.
    • Cause:
      • The path being revalidated is not being cached by Next.js (e.g., it's a dynamic route that isn't being accessed in a way that allows caching, or it's a client component that fetches data client-side).
      • The action is called from a client component, and the revalidation happens, but the client component itself doesn't re-fetch its data.
      • Using fetch without cache: 'force-cache' or next.tags.
    • Fix:
      • Ensure the data you expect to revalidate is fetched in a Server Component or a Server Action using fetch with appropriate caching headers or next.tags.
      • For client components, ensure they are receiving props from a Server Component that does revalidate, or trigger a client-side re-fetch after the action completes (though this often defeats the purpose of Actions).
      • Verify the revalidatePath argument matches the actual path being rendered.
  3. Optimistic Updates Not Rolling Back:

    • Symptom: UI shows optimistic state even after the server action fails.
    • Cause: The useActionState's returned state (which useOptimistic uses as its base) is not correctly reflecting the failure. This often happens if the server action throws an unhandled error instead of returning an error state.
    • Fix: Ensure your server action always returns a ProfileFormState (or equivalent) object, even on failure. Catch errors within the action and return an appropriate error message and errors object. useOptimistic relies on the base state from useActionState to revert.
  4. useFormStatus Not Updating:

    • Symptom: Submit button remains enabled/disabled incorrectly.
    • Cause: useFormStatus must be used within a component that is a direct descendant of a <form> element, or a component rendered within a <form> element. It cannot read the status of a form higher up the tree if there are other components in between that break the context.
    • Fix: Place the component using useFormStatus (like our SubmitButton) directly inside the <form> or ensure its parent is directly inside the <form>.
  5. FormData Issues with Complex Objects:

    • Symptom: Cannot directly pass complex JavaScript objects (e.g., nested arrays, objects) via FormData.
    • Cause: FormData is designed for simple key-value pairs, primarily strings or File objects. It flattens complex structures.
    • Fix: For complex data, consider serializing it to JSON and appending it as a single string field to FormData, then parsing it on the server. Alternatively, if not using a <form> element, you can directly pass arguments to a server action.
    // Client component
    import { myComplexAction } from './actions';
    
    async function handleSubmit(data: MyComplexDataType) {
      await myComplexAction(data); // Direct argument passing
    }
    
    // Server action
    'use server';
    import { MyComplexDataType } from '@/lib/types';
    
    export async function myComplexAction(data: MyComplexDataType) {
      // Process complex data directly
    }
    

Frequently Asked Questions

1. When should I use useActionState versus directly calling a server action?

useActionState is ideal when you need to manage the state returned by a server action, including pending status, error messages, and successful results, and have these states trigger re-renders in your component. It provides a structured way to handle the action's lifecycle. Directly calling a server action (e.g., await myAction(formData)) is suitable for simpler scenarios where you might not need the full state management capabilities, or when integrating with other state management solutions.

2. Can I use React Actions with client-side routing libraries like React Router?

React Actions are primarily designed to integrate deeply with React's rendering model and Next.js's App Router for server-side interactions and data revalidation. While you could technically call a server action from a component within a React Router app, you would lose the automatic revalidation benefits of revalidatePath and revalidateTag, and would need to manually manage data fetching and caching. For Next.js App Router, Actions are the idiomatic approach.

3. How do I handle global errors or unexpected server failures from an action?

Server actions should ideally catch their own errors and return a structured error state (as shown with ProfileFormState). For unhandled exceptions or critical server failures, Next.js will typically render an error page (error.tsx or global-error.tsx). You can also implement client-side error boundaries to catch errors during the action's execution phase, though the action itself should aim to return a predictable state. For network-level failures, useActionState will eventually resolve with an error, which you can then display.

4. Is it safe to pass sensitive data via FormData to a server action?

Yes, when using <form action={serverAction}>, the FormData is sent via a POST request. This is as secure as any other POST request to your server, provided your connection is HTTPS. The data is not exposed in the URL. Always validate and sanitize all incoming data on the server, regardless of how it's transmitted.

5. How do Actions impact bundle size and performance?

Server Actions themselves do not add to the client-side JavaScript bundle size because their code only runs on the server. React automatically generates a small client-side stub to invoke the server function. This can lead to smaller client bundles compared to traditional client-side data fetching where API client libraries and complex state management logic might be included. Performance is generally improved due to reduced client-side JavaScript, fewer network roundtrips (as data mutations and revalidation are often batched), and direct database access from the server.

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement