Back to Blog
useOptimistic and useActionState: When Your Instant UI Needs a Safety Net
11 min readAug 5, 20265 views

useOptimistic and useActionState: When Your Instant UI Needs a Safety Net

React 19's new hooks, useOptimistic and useActionState, are powerful tools for building highly responsive UIs and handling server actions. But combining them effectively, especially with form resets, requires a careful approach to state management to avoid unexpected behavior.

ReactFrontendWeb DevelopmentSoftware Design
Share

by Sunil Band

When Your Instant UI Needs a Safety Net

We've all been there: you click a button, and then... nothing. A spinner appears, the UI freezes, and you wait for the server to respond. In an age of instant gratification, this lag can feel like an eternity. We strive for optimistic UIs – where the UI assumes the action will succeed and updates immediately, then reconciles with the server's actual response later. It makes applications feel incredibly fast and responsive.

Historically, achieving this meant a lot of intricate local state management, juggling loading states, error states, and carefully rolling back changes if the server disagreed. It was boilerplate hell, error-prone, and often led to subtle race conditions or inconsistent UI states. React 19 brings useOptimistic to the table, directly addressing this pain point. It's designed to make optimistic updates a first-class concern, and it's a game-changer.

Alongside useOptimistic, we also have useActionState (which I've covered before in depth). useActionState is React's answer to simplifying form submissions and server mutations, tying form state directly to server actions and providing pending states, data, and errors. These two hooks feel like they're meant to work in harmony, and they absolutely are – but not without a few nuances, especially when dealing with form resets or complex interactions.

useOptimistic: The Optimistic Dream Made Real

Let's start with useOptimistic. Its core idea is simple: it gives you a way to show a temporary, speculative value in your UI before the actual asynchronous operation completes. If the operation succeeds, your UI naturally updates with the true server response. If it fails, useOptimistic provides a mechanism to revert to the last known good state.

Think of a chat application: you send a message, and it appears instantly in your feed, perhaps with a "sending..." indicator. The server processes it. If it goes through, the indicator vanishes. If it fails, the message might show an error icon, allowing you to retry. Before useOptimistic, you'd manage a local list of messages, add a pending one, update it on success, or remove it on error. It was messy.

Now, it's cleaner. You define your current state and a function to apply an optimistic update. useOptimistic returns two values: the optimistic value (what the UI should show now) and a function to add an optimistic item. This addOptimistic function is where you describe how to temporarily alter the state.

typescript
'use client';

import { useOptimistic, useState } from 'react';

interface Message {
  id: number;
  text: string;
  sending?: boolean;
}

let messageIdCounter = 0;

function deliverMessage(message: string): Promise<string> {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log('Server received:', message);
      // Simulate potential failure for demonstration
      if (Math.random() > 0.8) {
        throw new Error('Failed to deliver message');
      }
      resolve(message);
    }, 1000);
  });
}

export default function Chat() {
  const [messages, setMessages] = useState<Message[]>([]);
  // useOptimistic takes the current state and a function to apply optimistic updates
  // It returns the 'optimistic' state and a function to trigger an optimistic update.
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (currentMessages, newMessageText: string) => [
      ...currentMessages,
      { id: messageIdCounter++, text: newMessageText, sending: true }, // Optimistically add message
    ]
  );

  async function submitMessage(formData: FormData) {
    const messageText = formData.get('message') as string;
    if (!messageText) return;

    // Immediately add an optimistic message to the UI
    addOptimisticMessage(messageText);

    try {
      const deliveredText = await deliverMessage(messageText);
      // On success, the UI will naturally update when 'messages' state changes.
      // We only update the *actual* messages state here, not the optimistic one.
      setMessages(prev => prev.map(msg => 
        msg.text === deliveredText && msg.sending
          ? { ...msg, sending: false } 
          : msg
      ));

    } catch (error) {
      console.error('Message delivery failed:', error);
      // On failure, we need to manually revert by filtering out the failed optimistic message.
      // This is the tricky part: useOptimistic doesn't automatically revert on error.
      setMessages(prev => prev.filter(msg => !(msg.text === messageText && msg.sending)));
      alert(`Failed to send message: ${(error as Error).message}`);
    }
  }

  return (
    <section className="p-4 max-w-md mx-auto bg-gray-100 rounded-lg shadow-md">
      <h2 className="text-xl font-bold mb-4">Optimistic Chat</h2>
      <form action={submitMessage} className="flex gap-2 mb-4">
        <input
          type="text"
          name="message"
          placeholder="Say something..."
          className="flex-grow p-2 border border-gray-300 rounded"
          key={optimisticMessages.length} // Force re-render and clear input on submit
        />
        <button
          type="submit"
          className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
        >
          Send
        </button>
      </form>
      <ul className="space-y-2">
        {optimisticMessages.map((message) => (
          <li
            key={message.id}
            className={`p-2 rounded ${message.sending ? 'bg-yellow-200' : 'bg-green-200'}`}
          >
            {message.text}
            {message.sending && <span className="text-sm text-gray-600 ml-2">(sending...)</span>}
          </li>
        ))}
      </ul>
    </section>
  );
}

Notice how the addOptimisticMessage call happens before the await deliverMessage. This provides the instant UI feedback. The critical detail is the setMessages call after the await. This is where the actual state, the source of truth, is updated. If the deliverMessage fails, I have to manually filter out the sending message to revert. This highlights a nuance: useOptimistic provides the optimistic view, but managing the actual state and its reconciliation on success/failure is still your responsibility.

useActionState: Simplifying Form Mutations

Now let's bring in useActionState. This hook is fantastic for handling server actions (or any async function you want to treat like one) and managing the state associated with their execution. It gives you the data returned by the action, any error, and a pending boolean, all in a single, convenient hook.

typescript
'use client';

import { useActionState } from 'react';

interface FormState {
  message: string | null;
  errors: string[];
}

async function createUserAction(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  // Simulate a network request
  await new Promise(resolve => setTimeout(resolve, 1000));

  const username = formData.get('username') as string;
  const email = formData.get('email') as string;
  const errors: string[] = [];

  if (username.length < 3) {
    errors.push('Username must be at least 3 characters.');
  }
  if (!email.includes('@')) {
    errors.push('Invalid email address.');
  }

  if (errors.length > 0) {
    return { message: null, errors };
  } else {
    console.log(`User created: ${username} (${email})`);
    return { message: `User ${username} created successfully!`, errors: [] };
  }
}

export default function UserForm() {
  const [state, formAction, isPending] = useActionState(
    createUserAction,
    { message: null, errors: [] } // Initial state
  );

  return (
    <section className="p-4 max-w-md mx-auto bg-gray-100 rounded-lg shadow-md mt-8">
      <h2 className="text-xl font-bold mb-4">Create User</h2>
      <form action={formAction} 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"
            className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm"
            required
          />
        </div>
        <div>
          <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 p-2 border border-gray-300 rounded-md shadow-sm"
            required
          />
        </div>
        {state.errors.length > 0 && (
          <ul className="text-red-600 text-sm list-disc pl-5">
            {state.errors.map((error, i) => <li key={i}>{error}</li>)}
          </ul>
        )}
        {state.message && (
          <p className="text-green-600 text-sm">{state.message}</p>
        )}
        <button
          type="submit"
          disabled={isPending}
          className="w-full bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 disabled:opacity-50"
        >
          {isPending ? 'Creating...' : 'Create Account'}
        </button>
      </form>
    </section>
  );
}

useActionState manages the state, formAction (which you pass directly to a form's action prop), and isPending status. This significantly cleans up form handling compared to manually managing useState for loading, error, and data states, and then wiring up onSubmit handlers.

The Harmony and the Gotcha: Combining Them and Resetting Forms

So, how do useOptimistic and useActionState play together? Beautifully, in many cases. You can use useOptimistic to show immediate feedback for an action before useActionState's isPending flag even flips or the full server response comes back. This creates an even snappier UI.

Consider an "add item to cart" scenario. You click Add, useOptimistic immediately shows the item in the cart. Simultaneously, useActionState kicks off the server action to actually add it. If the server confirms, great. If it fails, useActionState will update its state.error, and your useOptimistic logic can then revert the UI.

Here's where the reset button issue (as the picked article highlights) comes in. When you combine these, especially with a form, you might have an input field that you want to reset after a successful submission. With useActionState, the state returned by the hook persists until the next action or component unmount. If your input's value is tied to this state, it won't clear automatically. If you're also using useOptimistic for an input field, it adds another layer.

The typical way to reset a controlled input in React is to set its value to an empty string. If you're using useActionState to manage form state and display results, its state doesn't automatically reset the form inputs. The state is for the result of the action, not the inputs themselves. For uncontrolled components using formAction, the browser handles input resets on successful submission, but this doesn't extend to the useActionState's result state.

The key insight here is to separate the form input state from the form action result state. While useActionState gives you state for the action's outcome, the actual input fields are often better managed with useRef for uncontrolled forms, or by linking them to the useOptimistic value if you want optimistic input updates.

Let's refine the chat example, this time integrating useActionState for the submission. The challenge is ensuring the input field clears correctly after an optimistic send and subsequent server confirmation.

typescript
'use client';

import { useOptimistic, useActionState, useRef } from 'react';

interface Message {
  id: number;
  text: string;
  sending?: boolean;
}

let messageIdCounter = 0;

interface ActionFormState {
  status: 'idle' | 'success' | 'error';
  message: string;
  errors?: string[];
}

async function sendMessageAction(
  prevState: ActionFormState,
  formData: FormData
): Promise<ActionFormState> {
  await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate network

  const messageText = formData.get('message') as string;
  if (!messageText) {
    return { status: 'error', message: 'Message cannot be empty.', errors: ['Message cannot be empty.'] };
  }

  // Simulate potential server-side failure
  if (Math.random() > 0.8) {
    console.error('Server failed to deliver message:', messageText);
    return { status: 'error', message: 'Failed to deliver message.', errors: ['Server error.'] };
  }

  console.log('Server successfully received:', messageText);
  return { status: 'success', message: `Message '${messageText}' sent.` };
}

export default function CombinedChat() {
  const [actionState, formAction, isPending] = useActionState(
    sendMessageAction,
    { status: 'idle', message: '' }
  );

  const [messages, setMessages] = useState<Message[]>([]);
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (currentMessages, newMessageText: string) => [
      ...currentMessages,
      { id: messageIdCounter++, text: newMessageText, sending: true },
    ]
  );

  const formRef = useRef<HTMLFormElement>(null);

  // Effect to handle server action results and update actual messages state
  useEffect(() => {
    if (actionState.status === 'success') {
      // Find the optimistic message that just succeeded and mark it as delivered
      // This assumes messageText is unique enough for identification
      const sentText = actionState.message.replace(/Message '(.*?)' sent./, '$1');
      setMessages(prev => prev.map(msg => 
        msg.text === sentText && msg.sending
          ? { ...msg, sending: false } 
          : msg
      ));
      // Reset the form after successful submission
      formRef.current?.reset();

    } else if (actionState.status === 'error') {
      // On error, revert the optimistic message by filtering it out
      // This requires knowing the original optimistic message text
      const failedText = (formRef.current?.elements.namedItem('message') as HTMLInputElement)?.value;
      if (failedText) {
        setMessages(prev => prev.filter(msg => !(msg.text === failedText && msg.sending)));
      }
      alert(`Error: ${actionState.message}`);
    }
  }, [actionState]);

  // Custom handler to integrate optimistic update with form action
  const handleSubmit = async (formData: FormData) => {
    const messageText = formData.get('message') as string;
    if (messageText) {
      addOptimisticMessage(messageText); // Optimistic UI update
    }
    // Trigger the server action
    await formAction(formData);
  };

  return (
    <section className="p-4 max-w-md mx-auto bg-gray-100 rounded-lg shadow-md mt-8">
      <h2 className="text-xl font-bold mb-4">Combined Optimistic Chat</h2>
      <form ref={formRef} action={handleSubmit} className="flex gap-2 mb-4">
        <input
          type="text"
          name="message"
          placeholder="Say something..."
          className="flex-grow p-2 border border-gray-300 rounded"
          disabled={isPending}
        />
        <button
          type="submit"
          disabled={isPending}
          className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
        >
          {isPending ? 'Sending...' : 'Send'}
        </button>
      </form>
      {actionState.errors && actionState.errors.length > 0 && (
        <p className="text-red-500 text-sm mb-2">{actionState.errors[0]}</p>
      )}
      <ul className="space-y-2">
        {optimisticMessages.map((message) => (
          <li
            key={message.id}
            className={`p-2 rounded ${message.sending ? 'bg-yellow-200' : 'bg-green-200'}`}
          >
            {message.text}
            {message.sending && <span className="text-sm text-gray-600 ml-2">(sending...)</span>}
          </li>
        ))}
      </ul>
    </section>
  );
}

Here, the formRef.current?.reset() inside the useEffect is crucial. After sendMessageAction completes successfully (and actionState.status becomes 'success'), we explicitly reset the form. For error cases, we also need to carefully revert the optimistic state. This pattern allows useOptimistic to give immediate feedback and useActionState to handle the backend interaction and its results, cleanly separating concerns and providing a robust reset mechanism.

Trade-offs and Considerations

While useOptimistic and useActionState are powerful, they aren't magic. You still need to manage your core state (the messages array in my example). useOptimistic provides a view of that state with speculative changes, but the source of truth must still be updated or reverted by you based on the actual server response.

Complexity in Reversion: Reverting an optimistic update on failure can be tricky. You need a way to identify and remove or modify the specific optimistic item you added. If your optimistic items aren't uniquely identifiable (e.g., just strings without temporary IDs), it becomes harder. In my example, I used messageIdCounter and then tried to match by text when reverting, which isn't always foolproof in a real app. A more robust solution might involve temporary client-side IDs that are then replaced with server-side IDs on success.

State Synchronization: When you have multiple sources of truth (your messages state and the optimistic optimisticMessages array), keeping them in sync requires careful thought. useOptimistic does most of the heavy lifting, but understanding when to update your base state vs. addOptimisticMessage is key. My pattern of addOptimisticMessage followed by setMessages in the useEffect after useActionState completes is one way to handle it, ensuring the base messages state eventually reflects the server's truth.

Error Handling: The useActionState provides a clear error path. It's up to you to decide how to present those errors to the user and how they impact the optimistic UI. Should an optimistic update immediately revert on any error, or only specific types? This depends on your UX requirements.

Wrapping up

useOptimistic and useActionState together offer a compelling pattern for building highly interactive and responsive React applications with server interactions. They abstract away a lot of the boilerplate traditionally associated with optimistic updates and form management. The trick, as always, is understanding their individual responsibilities and how their states interact. Specifically, for forms, remember to handle your input resets explicitly, often with formRef.current?.reset() in a useEffect triggered by useActionState's success state.

To try this out yourself, fork my example code and experiment with different success/failure rates for the simulated network requests. Play with how you revert optimistic changes on error and how you confirm them on success. Understanding these boundaries will make your next instant UI a joy to build, not a chore.

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. ☕