Expert70 min read

React 19: Server Actions and useActionState

Learn to build forms with server actions using useActionState, the React 19 replacement for useFormState.

Topics Covered:

useActionStateServer ActionsForm HandlingNext.js

Prerequisites:

  • React 19: Optimistic UI Updates with useOptimistic

Overview

useActionState (formerly useFormState) is React 19's hook for managing form state with server actions. It simplifies form handling in Next.js and React Server Components, providing built-in pending state and error handling. This tutorial covers server actions, form state management, error handling, and integration with Next.js App Router.

Lesson 1: Understanding Server Actions

Server actions are async functions that run on the server, called directly from client components. Benefits: • No API routes needed • Type-safe end-to-end • Progressive enhancement • Built-in security Server Action Requirements: • Marked with 'use server' • Async function • Can accept FormData or serializable data • Returns serializable response Next.js Integration: • Can be in separate files or inline • Automatically handled by Next.js • Works with App Router

Code Example:
// Server action (app/actions.ts)
'use server';

export async function submitForm(formData: FormData) {
  const email = formData.get('email') as string;
  
  // Validate
  if (!email || !email.includes('@')) {
    return { error: 'Invalid email' };
  }
  
  // Database operations, API calls, etc.
  await saveToDatabase(email);
  
  return { success: true, message: 'Saved!' };
}

// Client component usage
'use client';

import { submitForm } from '@/app/actions';

export function Form() {
  async function handleSubmit(formData: FormData) {
    const result = await submitForm(formData);
    console.log(result);
  }
  
  return (
    <form action={handleSubmit}>
      <input name="email" type="email" />
      <button type="submit">Submit</button>
    </form>
  );
}

// Server actions can also accept objects
'use server';

export async function createUser(data: {
  name: string;
  email: string;
}) {
  // Server-side validation
  if (!data.name || !data.email) {
    return { error: 'Missing fields' };
  }
  
  const user = await db.user.create({ data });
  return { user };
}

Server actions run on the server, eliminating the need for API routes. They're type-safe, secure, and work seamlessly with forms.

Lesson 2: useActionState Hook Basics

useActionState manages form state with server actions, providing pending state and previous state. Hook Signature: ```typescript const [state, formAction, pending] = useActionState( action, initialState ); ``` Parameters: • action: Server action function • initialState: Initial state value Returns: • state: Current state (result from last action) • formAction: Action wrapper for forms • pending: Boolean indicating if action is in progress Key Features: • Automatic pending state • Previous state tracking • Form integration • Error handling

Code Example:
import { useActionState } from 'react';
import { submitForm } from './actions';

interface FormState {
  message?: string;
  error?: string;
}

const initialState: FormState = {};

export function ContactForm() {
  const [state, formAction, pending] = useActionState(submitForm, initialState);
  
  return (
    <form action={formAction}>
      <input
        name="email"
        type="email"
        required
        disabled={pending}
      />
      
      {state.error && (
        <p className="error">{state.error}</p>
      )}
      
      {state.message && (
        <p className="success">{state.message}</p>
      )}
      
      <button type="submit" disabled={pending}>
        {pending ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

// Server action
'use server';

export async function submitForm(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const email = formData.get('email') as string;
  
  // Simulate API call
  await new Promise(resolve => setTimeout(resolve, 1000));
  
  if (!email || !email.includes('@')) {
    return {
      error: 'Please enter a valid email address',
    };
  }
  
  // Save to database
  await saveEmail(email);
  
  return {
    message: 'Thank you for subscribing!',
  };
}

useActionState wraps a server action, providing state management and pending status. The action receives previous state and FormData, returning new state.

Lesson 3: Advanced Form Patterns

Learn advanced patterns for complex forms with useActionState. Common Patterns: • Multi-step forms • File uploads • Dynamic form fields • Real-time validation • Field-level errors Best Practices: • Validate on server • Show loading states • Handle errors gracefully • Provide feedback • Optimistic updates where appropriate

Code Example:
// Multi-step form
function MultiStepForm() {
  const [step, setStep] = useState(1);
  const [state, formAction, pending] = useActionState(submitStep, {});
  
  return (
    <form action={formAction}>
      {step === 1 && <Step1 />}
      {step === 2 && <Step2 />}
      {step === 3 && <Step3 />}
      
      <div>
        {step > 1 && (
          <button type="button" onClick={() => setStep(s => s - 1)}>
            Back
          </button>
        )}
        <button type="submit" disabled={pending}>
          {step < 3 ? 'Next' : 'Submit'}
        </button>
      </div>
    </form>
  );
}

// File upload form
'use server';

export async function uploadFile(
  prevState: any,
  formData: FormData
) {
  const file = formData.get('file') as File;
  
  if (!file) {
    return { error: 'No file selected' };
  }
  
  if (file.size > 5 * 1024 * 1024) {
    return { error: 'File too large' };
  }
  
  // Upload to storage
  const url = await uploadToS3(file);
  
  return { success: true, url };
}

// Field-level errors
interface FormState {
  errors?: {
    email?: string;
    password?: string;
    name?: string;
  };
  message?: string;
}

'use server';

export async function registerUser(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const errors: FormState['errors'] = {};
  
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;
  const name = formData.get('name') as string;
  
  if (!email || !email.includes('@')) {
    errors.email = 'Invalid email';
  }
  
  if (!password || password.length < 8) {
    errors.password = 'Password must be at least 8 characters';
  }
  
  if (!name || name.length < 2) {
    errors.name = 'Name must be at least 2 characters';
  }
  
  if (Object.keys(errors).length > 0) {
    return { errors };
  }
  
  // Create user
  await createUser({ email, password, name });
  
  return { message: 'Account created successfully!' };
}

// Usage with field errors
function RegistrationForm() {
  const [state, formAction, pending] = useActionState(registerUser, {});
  
  return (
    <form action={formAction}>
      <div>
        <input name="email" type="email" />
        {state.errors?.email && (
          <span className="error">{state.errors.email}</span>
        )}
      </div>
      
      <div>
        <input name="password" type="password" />
        {state.errors?.password && (
          <span className="error">{state.errors.password}</span>
        )}
      </div>
      
      <button type="submit" disabled={pending}>
        Register
      </button>
    </form>
  );
}

useActionState handles complex forms with multi-step flows, file uploads, and field-level validation. Server-side validation ensures data integrity.

Lesson 4: Combining with useOptimistic

Combine useActionState with useOptimistic for the best user experience. Combination Benefits: • Instant UI feedback • Server-side validation • Automatic rollback on errors • Best of both worlds Pattern: • useActionState for form submission • useOptimistic for immediate feedback • Handle errors gracefully

Code Example:
// Combining useActionState and useOptimistic
function CommentForm({ postId }: { postId: number }) {
  const [comments, setComments] = useState<Comment[]>([]);
  const [state, formAction, pending] = useActionState(
    addComment,
    { comments }
  );
  
  const [optimisticComments, addOptimistic] = useOptimistic(
    comments,
    (current, newComment: Comment) => [newComment, ...current]
  );
  
  // Update comments when action succeeds
  useEffect(() => {
    if (state.comments) {
      setComments(state.comments);
    }
  }, [state.comments]);
  
  async function handleSubmit(formData: FormData) {
    const text = formData.get('text') as string;
    
    // Optimistic update
    const tempComment: Comment = {
      id: `temp-${Date.now()}`,
      text,
      author: currentUser,
      createdAt: new Date(),
    };
    addOptimistic(tempComment);
    
    // Submit with action
    await formAction(formData);
    
    // State updates automatically via useEffect
  }
  
  return (
    <div>
      {optimisticComments.map(comment => (
        <Comment key={comment.id} comment={comment} />
      ))}
      <form action={handleSubmit}>
        <textarea name="text" disabled={pending} />
        <button type="submit" disabled={pending}>
          {pending ? 'Posting...' : 'Post Comment'}
        </button>
      </form>
    </div>
  );
}

// Server action
'use server';

export async function addComment(
  prevState: { comments: Comment[] },
  formData: FormData
) {
  const text = formData.get('text') as string;
  
  if (!text || text.trim().length === 0) {
    return {
      ...prevState,
      error: 'Comment cannot be empty',
    };
  }
  
  const comment = await createComment({
    text,
    postId: formData.get('postId'),
  });
  
  return {
    comments: [comment, ...prevState.comments],
  };
}

Combining useActionState with useOptimistic provides instant feedback while maintaining server-side validation. Users see immediate updates, and errors are handled gracefully.

Lesson 5: Migration from useFormState

useActionState replaces useFormState in React 19. Here's how to migrate. Key Changes: • Renamed: useFormState → useActionState • Same API • Better TypeScript support • Improved performance Migration Steps: 1. Update imports 2. Rename hook usage 3. Test thoroughly 4. Update documentation

Code Example:
// Before (React 18 - useFormState)
import { useFormState } from 'react-dom';

function Form() {
  const [state, formAction] = useFormState(submitForm, initialState);
  // ...
}

// After (React 19 - useActionState)
import { useActionState } from 'react';

function Form() {
  const [state, formAction, pending] = useActionState(
    submitForm,
    initialState
  );
  // pending is now available!
  // ...
}

// Migration checklist:
// ✅ Rename import
// ✅ Rename hook
// ✅ Add pending state usage (optional but recommended)
// ✅ Update TypeScript types if needed
// ✅ Test all forms

// Example migration
// OLD
const [state, formAction] = useFormState(submitForm, {});

// NEW
const [state, formAction, pending] = useActionState(submitForm, {});

// You can now use pending
<button disabled={pending || state.loading}>
  {pending ? 'Submitting...' : 'Submit'}
</button>

Migration is straightforward - just rename the hook. The API is identical, and you gain access to the pending state for better UX.

Conclusion

useActionState simplifies form handling with server actions. It provides built-in state management, pending status, and seamless integration with Next.js. Combine with useOptimistic for the best user experience. Always validate on the server for security.