Learn to build robust forms with validation, pending states, and optimistic updates.

Using useActionState

useActionState manages form state based on the result of a Server Action:

tsx
'use client';

import { useActionState } from 'react';
import { createUser } from './actions';

type State = {
  error: string | null;
  success: boolean;
};

export function SignupForm() {
  const [state, formAction, isPending] = useActionState<State, FormData>(
    createUser,
    { error: null, success: false }
  );
  
  return (
    <form action={formAction}>
      <input name="email" type="email" disabled={isPending} />
      <input name="password" type="password" disabled={isPending} />
      
      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p className="success">Account created!</p>}
      
      <button disabled={isPending}>
        {isPending ? 'Creating...' : 'Sign Up'}
      </button>
    </form>
  );
}
tsx
// actions.ts
'use server';

export async function createUser(prevState: State, formData: FormData) {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;
  
  // Validation
  if (!email.includes('@')) {
    return { error: 'Invalid email', success: false };
  }
  
  if (password.length < 8) {
    return { error: 'Password must be at least 8 characters', success: false };
  }
  
  try {
    await db.users.create({ data: { email, password: hash(password) } });
    return { error: null, success: true };
  } catch (e) {
    return { error: 'Email already exists', success: false };
  }
}

Using useFormStatus

useFormStatus reads the status of a parent form:

tsx
'use client';

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending, data, method } = useFormStatus();
  
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

// Usage - SubmitButton must be INSIDE the form
function ContactForm() {
  return (
    <form action={sendMessage}>
      <input name="message" />
      <SubmitButton /> {/* Automatically knows form state */}
    </form>
  );
}

Optimistic Updates with useOptimistic

Show immediate feedback while the action processes:

tsx
'use client';

import { useOptimistic } from 'react';
import { addTodo } from './actions';

type Todo = { id: string; text: string; pending?: boolean };

export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo: string) => [
      ...state,
      { id: crypto.randomUUID(), text: newTodo, pending: true }
    ]
  );
  
  async function handleSubmit(formData: FormData) {
    const text = formData.get('todo') as string;
    addOptimisticTodo(text); // Show immediately
    await addTodo(text);      // Actually save
  }
  
  return (
    <>
      <form action={handleSubmit}>
        <input name="todo" />
        <button>Add</button>
      </form>
      <ul>
        {optimisticTodos.map(todo => (
          <li 
            key={todo.id}
            style={{ opacity: todo.pending ? 0.5 : 1 }}
          >
            {todo.text}
          </li>
        ))}
      </ul>
    </>
  );
}

Non-Form Usage

Server Actions can be called outside forms:

tsx
'use client';

import { deletePost } from './actions';
import { useTransition } from 'react';

function DeleteButton({ postId }) {
  const [isPending, startTransition] = useTransition();
  
  const handleDelete = () => {
    startTransition(async () => {
      await deletePost(postId);
    });
  };
  
  return (
    <button onClick={handleDelete} disabled={isPending}>
      {isPending ? 'Deleting...' : 'Delete'}
    </button>
  );
}
✓ Completed