Back to Blog
React 19's useActionState: The Form State Hook You Didn't Know You Needed
10 min readJul 28, 20264 views

React 19's useActionState: The Form State Hook You Didn't Know You Needed

Disabling a submit button is the bare minimum for form UX. React 19's useActionState goes further, offering a robust way to manage form loading, error, and data states right within your actions, making optimistic updates and server-driven UIs more natural.

FrontendReactWeb DevelopmentSoftware DesignUI
Share

by Sunil Band

Forms are Harder Than They Look

Every form I've built, especially those that interact with a backend, shares a common set of concerns. You've got user input, validation, submission, and then the critical feedback loop: telling the user if it worked, if it failed, and preventing duplicate submissions. Just disabling the submit button on onClick is a start, but it barely scratches the surface of what a good user experience demands. What if the network is flaky? What if the server sends back a validation error? How do you show that?

For years, I'd wire up three pieces of state for every form submission: isLoading, error, and data (or result). It was boilerplate, and honestly, a bit repetitive. If you needed to do an optimistic update, it got even more tangled. React 19, with its new useActionState hook, finally offers a cohesive answer to this problem, especially for those leaning into Server Actions.

The Boilerplate Problem

Let's be real, a typical form submission without useActionState looks something like this. You're probably tired of writing it, I know I am.

typescript
import React, { useState } from 'react';

async function createUser(formData: FormData) {
  // Simulate a network request
  return new Promise<{ message: string; error?: string }>((resolve) => {
    setTimeout(() => {
      const name = formData.get('name');
      const email = formData.get('email');

      if (!name || !email) {
        resolve({ message: 'Validation failed', error: 'Name and email are required.' });
      } else if (email.toString().includes('fail')) {
        resolve({ message: 'Failed to create user', error: 'Email already exists or is invalid.' });
      } else {
        resolve({ message: `User ${name} created successfully!`, error: undefined });
      }
    }, 1500);
  });
}

export default function UserSignupFormOld() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | undefined>(undefined);
  const [message, setMessage] = useState<string | undefined>(undefined);

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setLoading(true);
    setError(undefined);
    setMessage(undefined);

    const formData = new FormData(event.currentTarget);
    try {
      const response = await createUser(formData);
      if (response.error) {
        setError(response.error);
        setMessage(undefined);
      } else {
        setMessage(response.message);
        // Maybe clear form, etc.
      }
    } catch (err) {
      setError('An unexpected error occurred.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="p-4 border rounded shadow-sm">
      <h2 className="text-xl font-bold mb-4">Sign Up (Old Way)</h2>
      <div className="mb-3">
        <label htmlFor="name" className="block text-sm font-medium text-gray-700">Name</label>
        <input
          type="text"
          id="name"
          name="name"
          className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
          disabled={loading}
        />
      </div>
      <div className="mb-3">
        <label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
        <input
          type="email"
          id="email"
          name="email"
          className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
          disabled={loading}
        />
      </div>
      <button
        type="submit"
        className={`px-4 py-2 rounded text-white ${loading ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'}`}
        disabled={loading}
      >
        {loading ? 'Submitting...' : 'Sign Up'}
      </button>
      {message && <p className="mt-3 text-green-600">{message}</p>}
      {error && <p className="mt-3 text-red-600">{error}</p>}
    </form>
  );
}

This isn't bad, but it's a lot of manual state management for a very common pattern. Every form, every submission, you're repeating setLoading(true), setError(undefined), setMessage(undefined), then wrapping your async call in a try...catch...finally block. This quickly becomes tedious and error-prone. What if you forget to reset error? Or miss a setLoading(false) in a specific branch? Bugs happen.

Enter useActionState

useActionState is a new hook in React 19 (currently in Canary). It's designed specifically to streamline this common pattern of handling form submissions or any async action triggered by user interaction. It takes your asynchronous action function and gives you back the last result of that action, and a pending state. It wraps the execution, automatically managing the loading state and propagating the result.

Its signature is simple:

const [state, formAction, isPending] = useActionState(action, initialResult, identifier?)

  • action: Your async function that performs the side effect (e.g., submitting data to an API). This function receives the previous state and the FormData object (or any arguments you pass if not used with a form's action prop).
  • initialResult: The initial value for the state returned by your action, before any submissions have occurred.
  • identifier (optional): A string identifier useful for debugging or when dealing with multiple useActionState hooks on the same page. It's especially useful for Server Components, but also good practice in client-only forms.
  • state: The last returned value from your action function. This is where your success message, error message, or validation failures will live.
  • formAction: A new function you can pass directly to a <form>'s action prop or call directly with formData.
  • isPending: A boolean indicating if the action is currently in flight.

This simplifies the component logic significantly. Instead of managing three pieces of state manually, useActionState gives you the state (which can contain your message and error) and isPending (which replaces isLoading).

typescript
import React from 'react';
import { useActionState } from 'react-dom'; // From 'react-dom' in React 19 Canary

// This function can be a Server Action, or just a regular async function on the client.
async function createUserAction(prevState: { message?: string; error?: string } | undefined, formData: FormData) {
  // Simulate a network request
  return new Promise<{ message: string; error?: string }>((resolve) => {
    setTimeout(() => {
      const name = formData.get('name');
      const email = formData.get('email');

      if (!name || !email) {
        resolve({ message: 'Validation failed', error: 'Name and email are required.' });
      } else if (email.toString().includes('fail')) {
        resolve({ message: 'Failed to create user', error: 'Email already exists or is invalid.' });
      } else {
        resolve({ message: `User ${name} created successfully!`, error: undefined });
      }
    }, 1500);
  });
}

export default function UserSignupFormNew() {
  // useActionState manages the loading and result state for us
  const [formState, formAction, isPending] = useActionState(createUserAction, undefined);

  return (
    // Pass formAction directly to the form's action prop
    <form action={formAction} className="p-4 border rounded shadow-sm">
      <h2 className="text-xl font-bold mb-4">Sign Up (New Way)</h2>
      <div className="mb-3">
        <label htmlFor="name" className="block text-sm font-medium text-gray-700">Name</label>
        <input
          type="text"
          id="name"
          name="name"
          className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
          disabled={isPending} // use isPending to disable inputs and button
        />
      </div>
      <div className="mb-3">
        <label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
        <input
          type="email"
          id="email"
          name="email"
          className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
          disabled={isPending}
        />
      </div>
      <button
        type="submit"
        className={`px-4 py-2 rounded text-white ${isPending ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'}`}
        disabled={isPending}
      >
        {isPending ? 'Submitting...' : 'Sign Up'}
      </button>
      {formState?.message && !formState.error && <p className="mt-3 text-green-600">{formState.message}</p>}
      {formState?.error && <p className="mt-3 text-red-600">{formState.error}</p>}
    </form>
  );
}

Notice how much cleaner the UserSignupFormNew component is. We've removed all the useState calls for loading, error, and message. The createUserAction function is now responsible for returning the entire result state, which useActionState then makes available as formState. The isPending variable handles the loading UI. This isn't just less code; it's a more declarative way to express form state, making it harder to introduce bugs related to unhandled loading or error states.

Optimistic UI with useActionState

One of the coolest features enabled by useActionState (especially in conjunction with useOptimistic for Server Actions) is how naturally it supports optimistic updates. While useActionState itself doesn't provide the optimistic state, it sets the stage perfectly. When used within a <form action={formAction}>, React can intelligently re-render only the affected parts, making optimistic updates feel native.

For instance, if you're adding an item to a list, you can immediately display the new item while the server request is in flight. If the request fails, you can revert the UI. This significantly improves perceived performance. useActionState gives you isPending to know when to show the optimistic state and formState to know if you need to revert it based on a server error.

typescript
// Hypothetical example illustrating the idea with a list of items
import React, { useRef } from 'react';
import { useActionState, useOptimistic } from 'react-dom';

async function addItem(previousItems: string[], formData: FormData) {
  const newItemText = formData.get('itemText')?.toString();

  if (!newItemText) {
    return previousItems; // No change if no text
  }

  return new Promise<string[]>((resolve) => {
    setTimeout(() => {
      if (newItemText.includes('fail')) {
        // Simulate server failure, return original state or specific error
        resolve(previousItems);
      } else {
        resolve([...previousItems, newItemText]);
      }
    }, 1500);
  });
}

export default function OptimisticAddItem() {
  // Imagine initialItems comes from a server or prop
  const initialItems = ['Learn React', 'Write a blog post'];

  // useOptimistic state, for immediate UI update
  const [optimisticItems, addOptimisticItem] = useOptimistic(initialItems, (state, newItemText: string) => {
    return [...state, newItemText];
  });

  // useActionState for actual submission and final state update
  // The action here receives the *current* optimistic state (which might be different from initialItems if we're pending)
  const [items, formAction, isPending] = useActionState(async (prevItems: string[], formData: FormData) => {
    const newItemText = formData.get('itemText')?.toString();
    if (!newItemText) return prevItems; // Guard against empty submission

    addOptimisticItem(newItemText); // Immediately update optimistic state

    const result = await addItem(prevItems, formData); // Perform the actual async operation

    // If the server action returns a different state (e.g., due to failure or mutation),
    // useActionState will update `items` accordingly, effectively 'reverting' the optimistic state.
    // In a real app, you'd likely return a richer object with success/error.
    return result;

  }, initialItems);

  const formRef = useRef<HTMLFormElement>(null);

  // When the form successfully submits, clear the input field
  React.useEffect(() => {
    if (!isPending && formRef.current) {
      formRef.current.reset();
    }
  }, [isPending]);

  return (
    <div className="p-4 border rounded shadow-sm">
      <h2 className="text-xl font-bold mb-4">Todo List (Optimistic Update)</h2>
      <ul className="list-disc pl-5 mb-4">
        {optimisticItems.map((item, index) => (
          <li key={index} className={isPending && item === optimisticItems[optimisticItems.length - 1] ? 'text-gray-500 italic' : ''}>
            {item}
          </li>
        ))}
      </ul>
      <form ref={formRef} action={formAction} className="flex gap-2">
        <input
          type="text"
          name="itemText"
          placeholder="Add a new item..."
          className="flex-grow border border-gray-300 rounded-md shadow-sm p-2"
          disabled={isPending}
        />
        <button
          type="submit"
          className={`px-4 py-2 rounded text-white ${isPending ? 'bg-gray-400 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700'}`}
          disabled={isPending}
        >
          {isPending ? 'Adding...' : 'Add Item'}
        </button>
      </form>
      {isPending && <p className="mt-3 text-blue-600">Adding item optimistically...</p>}
      {/* In a real app, you'd handle specific errors from addItem's return type */}
      {items !== optimisticItems && <p className="mt-3 text-red-600">Failed to add item. Please try again.</p>}
    </div>
  );
}

This example is a bit simplified for brevity, but it shows the power. addOptimisticItem immediately updates the optimisticItems list, giving the user instant feedback. useActionState then handles the actual server call. If addItem fails or returns a state that doesn't match the optimistic one, useActionState will update the items state, implicitly correcting the UI. It's a powerful pattern for building responsive, fast-feeling applications, especially with the integration into Server Actions where useOptimistic truly shines.

Trade-offs and Considerations

While useActionState drastically cleans up form logic, it's essential to understand its sweet spot and its limitations:

  1. React 19 Canary: This hook is part of React 19, which is still in Canary. You'll need to use react@canary and react-dom@canary to experiment with it. This means it's not production-ready for most applications yet, but it's a strong indicator of where React is heading.
  2. Server Actions Integration: While useActionState can be used with client-side async functions, its design is heavily influenced by and optimized for React Server Components and Server Actions. In a Next.js or Remix application utilizing Server Actions, useActionState becomes even more powerful as it naturally bridges client-side UI with server-side mutations without explicit API calls.
  3. Error Handling: The action function passed to useActionState should return its result, including any errors or success messages. It doesn't use try/catch within the useActionState call itself to handle rejected promises; instead, the error should be part of the returned state. This encourages a functional approach to result handling.
  4. Complex Client-Side Validation: For very complex, synchronous client-side validation that needs to happen before the action even fires, you'll still need traditional useState or a form library like Zod or React Hook Form. useActionState is primarily about managing the asynchronous action's state and result, not the interim client-side input validity.

For most common form submission scenarios, especially those involving network requests, useActionState is a significant step forward. It moves repetitive state management out of your component and into a declarative hook, letting you focus on the actual business logic of your action.

Wrapping up

useActionState is poised to be a game-changer for how we handle form submissions and asynchronous actions in React. It's not just about less code; it's about a more robust, declarative pattern that naturally supports optimistic UI updates and integrates seamlessly with React's evolving server-first paradigm. If you're building forms that interact with a backend, which is pretty much every interesting application, this hook is a massive win.

My advice? Get your hands dirty with it. Set up a quick project with react@canary and react-dom@canary. Try converting one of your existing client-side forms to use useActionState. Pay attention to how the action function's return type now dictates your form's feedback. Then, try adding useOptimistic to see how effortlessly you can build a truly responsive UI. This is where React is headed, and understanding these new primitives early will give you a significant edge.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕