Squares Icon

Colum Kelly

useOptimistic Won't Save You

Jan 15, 2026

REACT, TYPESCRIPT

|

12 MIN READ

Squares Icon

Updating the UI immediately in response to user interaction while the operation completes in the background. This decouples interface responsiveness from network latency. The canonical example is the "like" button.

You would think implementing this in React would be simple, but doing it without introducing visual glitches or race conditions is not. React 19's useOptimistic hook appears to make the pattern a first-class citizen. However, I would argue that with the advent of Concurrent React, optimistic UI has gotten even more complicated and harder to implement.

Let's explore why manual optimistic updates were historically fragile, how useOptimistic can help, and why it's not a silver bullet.

To understand the architectural significance of useOptimistic, we should first examine the limitations of previous user-land implementations.

Example 1. Bad Optimistic Update

The most naive approach involves updating the UI on user interaction and again each time the server responds. There are a few issues here and a number of ways that the UI can get out of sync.

Naive Optimistic Update
import { useState } from 'react';
import { toggleLike } from './api';
import { Heart } from 'lucide-react';
import { NetworkPanel } from './NetworkPanel';
import './styles.css';

export default function App() {
  const [liked, setLiked] = useState(false);

  async function handleToggle() {
    const nextLiked = !liked;
    
    // 1. Optimistic Update
    // We immediately flip the state locally
    setLiked(nextLiked);

    try {
      // 2. Server Request
      // Wait for the server to complete the operation
    const updated = await toggleLike(nextLiked);
    
      // 3. Sync
      // Overwrite local state with server's state
      setLiked(updated); // Try commenting this line out
    } catch (e) {
      // Revert on error - Causes sync issues
      setLiked(!nextLiked);
    }
  }

  return (
    <div className="app-container">
      <div className="card">
        <button 
          onClick={handleToggle}
          className={'like-button ' + (liked ? 'liked' : '')}
        >
          <Heart fill={liked ? "currentColor" : "none"} size={48} />
        </button>
      </div>
      <NetworkPanel />
    </div>
  );
}

If we spam the button, the state flicks back and forth as the requests complete. If we randomize the latency we'll see race conditions and the final state becomes a toss up.

Commenting out line #24 is a slight improvement. This stops the final sync with the server and solves the flickering, but it creates a new issue. If the UI goes out of sync it won't come back again until another bug occurs or the page is reloaded.

With that line still commented out, toggle "Always Error" and make a pair of requests. The UI reverts from the latter optimistic state to the former optimistic state with no way to reconcile the state from the server.

Example 2. Better Optimistic Update

To prevent these issues we need to maintain two separate states: the server state and the optimistic state. We can then manually synchronize them, using a ref to track the latest request ID and discard stale responses.

This works, but it requires significant boilerplate to handle race conditions correctly.

Manual Synchronization
import { useState, useRef } from 'react';
import { toggleLike } from './api';
import { Heart } from 'lucide-react';
import { NetworkPanel } from './NetworkPanel';
import './styles.css';

export default function App() {
const [serverLiked, setServerLiked] = useState(false);
const [optimisticLiked, setOptimisticLiked] = useState(false);
const callIdRef = useRef(null);

async function handleToggle() {
  const nextLiked = !optimisticLiked;

  // 1. Optimistic Update
  setOptimisticLiked(nextLiked);

  const callId = Math.random().toString(36);
  callIdRef.current = callId;

  try {
    // 2. Server Request
    const updated = await toggleLike(nextLiked);

    // 3. Sync (Only if last call)
    if (callIdRef.current === callId) {
      setServerLiked(updated);
      setOptimisticLiked(updated);
    }
  } catch (e) {
    // Revert
    if (callIdRef.current === callId) {
      setOptimisticLiked(serverLiked);
    }
  }
}

return (
  <div className="app-container">
    <div className="card">
      <button 
        onClick={handleToggle}
        className={'like-button ' + (optimisticLiked ? 'liked' : '')}
      >
        <Heart fill={optimisticLiked ? "currentColor" : "none"} size={48} />
      </button>
    </div>
    <NetworkPanel />
  </div>
);
}

While this approach solves the issues from the previous example, it still has problems:

Boilerplate & Complexity

We now have to manage two separate states, a ref for tracking request IDs, and complex imperative logic in our event handlers. We have to manually ensure we check the callId in both success and failure cases.

Alternatively, we could use an abort controller to cancel any ongoing requests when a new one is made, but this would be a similar amount of code.

Transitions

What happens when the update happens in a transition? Concurrent React uses transitions for non-blocking updates. In the next example a todo is updated using a form action, which React handles as a transition.

Example 3. Optimistic Update Within a Transition

Optimistic Update Within a Transition
import { useState } from 'react';
import { updateTodo } from './api';
import { TodoList } from './TodoList';
import './styles.css';

const initialTodos = [
  { id: '1', title: 'Walk the dog', completed: false },
];

export default function App() {
  const [todos, setTodos] = useState(initialTodos);

  async function toggleTodo(formData) {
    const id = formData.get('id');
    const todo = todos.find(t => t.id === id);
    const nextTodo = { ...todo, completed: !todo.completed };
    
    // 1. Optimistic Update
    // Now this doesn't work because we're in a transition
    setTodos(prev => prev.map(t => 
      t.id === id ? nextTodo : t
    ));

    try {
      // 2. Server Request
      const updated = await updateTodo(id, nextTodo);
      
      // 3. Sync
      // This is the only update that we'll see
      setTodos(prev => prev.map(t => 
        t.id === id ? updated : t
      ));
    } catch (e) {
      // Revert on error
      setTodos(prev => prev.map(t => 
        t.id === id ? todo : t
      ));
    }
  }

  return (
    <TodoList todos={todos} toggleTodoAction={toggleTodo} />
  );
}

It doesn't work. When the state updater function is called from within a transition, it doesn't cause an immediate re-render, which we need to show the optimistic state. This is where useOptimistic comes in. It allows us to update the UI immediately within a transition. It also batches reversions until the end of the last transition.

Example 4. useOptimistic

useOptimistic
import { useState, useOptimistic, startTransition } from "react";
import { updateTodo } from './api';
import { TodoList } from './TodoList';
import './styles.css';

const initialTodos = [
  { id: '1', title: 'Walk the dog', completed: false },
];

export default function App() {
  const [todos, setTodos] = useState(initialTodos);
  const [optimisticTodos, toggleOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos, id) => {
      return currentTodos.map((t) =>
        t.id === id ? { ...t, completed: !t.completed } : t
      );
    }
  );
  
  async function toggleTodo(formData) {
    const id = formData.get('id');
    const todo = optimisticTodos.find(t => t.id === id);
    const nextTodo = { ...todo, completed: !todo.completed };
    
    // 1. Optimistic Update
    toggleOptimisticTodo(id);

    // 2. Server Request
    try {
      const updated = await updateTodo(id, nextTodo);
    
      // 3. Sync
      startTransition(() => {
        setTodos((prev) => prev.map((t) => (t.id === id ? updated : t)));
      });
    } catch(e) {
      // No need to revert on error, this happens automatically
    }

  }

  return (
    <TodoList todos={optimisticTodos} toggleTodoAction={toggleTodo} />
  );
}

It appears to work. It's slightly simpler than the earlier example with the callIds ref, but it's still a bit verbose. The other issue is that using a transition doesn't prevent race conditions. It's possible to get the UI out of sync by spamming the checkbox with the latency randomized. The React docs have a note on this:

This is because the updates are scheduled asynchronously, and React loses context of the order across the async boundary.

Okay, so we're failing to handle race conditions again. There's more:

This is expected, because Actions within a Transition do not guarantee execution order. For common use cases, React provides higher-level abstractions like useActionState and <form> actions that handle ordering for you. For advanced use cases, you’ll need to implement your own queuing and abort logic to handle this.

In other words, "We have an API for that too."

Example 5. Just for Kicks

Here's one last example using useActionState and useOptimistic together. I wouldn't want to do this for one checkbox, but it does solve the issue of race conditions. Take a guess how React gets the execution order correct and then try it.

useOptimistic + useActionState
import { useState, useOptimistic, useActionState, startTransition } from "react";
import { updateTodo } from './api';
import { TodoList } from './TodoList';
import './styles.css';

const initialTodos = [
  { id: '1', title: 'Walk the dog', completed: false },
];

export default function App() {
  const [todos, setTodos] = useState(initialTodos);
  const [optimisticTodos, toggleOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos, id) => {
      return currentTodos.map((t) =>
        t.id === id ? { ...t, completed: !t.completed } : t
      );
    }
  );
  
  // useActionState ensures execution order
  const [state, formAction] = useActionState(
    async (prevState, formData) => {
      const id = formData.get('id');
      const todo = optimisticTodos.find(t => t.id === id);
      const nextTodo = { ...todo, completed: !todo.completed };

      try {
        const updated = await updateTodo(id, nextTodo);
      
        // Sync with server state
        startTransition(() => {
          setTodos((prev) => prev.map((t) => (t.id === id ? updated : t)));
        });
        
        return updated;
      } catch(e) {
        // No need to revert on error, this happens automatically
      }
    },
    null
  );

  async function toggleTodoAction(formData) {
    toggleOptimisticTodo(formData.get('id'));
    await formAction(formData);
  }

  return (
    <TodoList todos={optimisticTodos} toggleTodoAction={toggleTodoAction} />
  );
}

The requests are queued and processed sequentially, with only one request made at a time. Now we're handling errors, race conditions, and we're updating the UI within a transition.

The fact is that these new APIs don't make these situations any easier to handle than before. useOptimistic is useful but it doesn't simplify optimistic UI and doesn't solve the issue of race conditions.

I wanted to write about this hook because I spent a lot of time trying to understand it myself. You won't believe how many Medium articles there are about useOptimistic that don't even mention transitions.

Frankly, we should leave these APIs to the library and framework authors. I recommend you watch Ricky Hanlon's talk Async React from React Conf. After an underwhelming demo with a string of mistakes, Ricky says:

“So I think one of the key points here is, and you saw me struggling with it – is that writing all of these individual features is kind of a pain in the ass if I’m honest.”

And that's just it. The React team created a lot of controversy when they recommended that React be used with a framework, but this is why. These new APIs are a pain in the ass and they don't reduce the amount of boilerplate we have to write, or reduce the likelyhood of introducing bugs.

The intention is for the framework authors to build their routers and data layers using these APIs and for us to get the benefits without having to deal with the complexity ourselves.

Loading comments…