| |

React 54 ⚛️ Optimistic UI State Updates with useOptimistic

⏱️ Estimated read: 24 min | |

A user clicks a Like button. The request goes to the server, waits for a response, and only then updates the count. On a fast connection, this feels acceptable. On a slow one, the user stares at an unchanged button, wondering if the click registered. They click again. Now two requests are in flight. This is the fundamental problem with server-dependent UI: the interface waits for confirmation before showing the result. The useOptimistic hook exists to break that dependency, letting the UI display the expected outcome immediately while the server work happens in the background, and rolling back automatically if the operation fails.

Key point: useOptimistic is not a state management tool. It is a temporary overlay that exists only while an async Action is pending. When the Action completes, the overlay disappears, and the UI renders the real state that the server confirmed.


Why useOptimistic exists

The perceived latency problem. Every network request introduces delay. Even a fast API call takes tens of milliseconds, and on mobile connections it can take hundreds. During that window, the user has no feedback that their action was received. The conventional solution, a loading spinner, is honest but unsatisfying. Optimistic updates take a different approach: show the user what they expect to happen, then reconcile with reality when the server responds.

The state synchronization problem. Before useOptimistic, implementing optimistic updates required manual state juggling. A developer would keep two copies of the data, one for the real state and one for the optimistic overlay, and write logic to swap between them when the request completed or failed. This logic was error-prone, particularly around rollback on error and concurrent updates from other sources. The hook encapsulates this pattern into a single declarative API .

The concurrent update problem. If a user adds an item to a list optimistically, and during the pending request another user adds a different item, a naive optimistic update would show only the first user’s addition, missing the second. React’s useOptimistic with a reducer function solves this by re-running the reducer with the latest base state whenever the underlying data changes, ensuring the optimistic overlay is always applied on top of the current truth .

The Action integration problem. Optimistic updates need a well-defined lifecycle: they start when an Action begins and end when it completes, whether successfully or with an error. React’s Actions system, which wraps async functions in transitions, provides exactly this boundary. useOptimistic is designed to work within this boundary, and the optimistic state automatically reverts when the transition ends .

The rollback guarantee. When an optimistic update is applied, the UI shows a state that does not yet exist on the server. If the server operation fails, that state must disappear. React handles this automatically: if the Action throws an error, the transition ends, and the optimistic state reverts to the underlying value. The developer does not write rollback logic; it happens as a consequence of the transition lifecycle .


a. How useOptimistic works

useOptimistic accepts a base state and an optional update function, and returns a tuple of the optimistic state and a dispatch function. The optimistic state equals the base state when no Action is pending. During an Action, it equals the result of applying the update function to the base state and the optimistic value .

The basic signature

const [optimisticState, addOptimistic] = useOptimistic(
  state,
  (currentState, optimisticValue) => {
    // Return the new optimistic state.
  }
);

The first argument is the canonical state. This is the value that the component receives from its parent, from props, or from a React state hook. It represents the truth as last confirmed by the server. The second argument is an update function that describes how to apply an optimistic change. The hook returns the current optimistic state and a function to dispatch an optimistic update .

The optimistic lifecycle

When addOptimistic is called inside an Action, React renders the component with the optimistic state computed by the update function. The base state remains unchanged. When the Action completes, React re-renders with the base state, which the parent should have updated to reflect the server’s response. If the Action fails, the base state is unchanged, and the UI reverts to what it showed before the optimistic update .

function LikeButton({ initialCount }) {
  const [count, setCount] = useState(initialCount);
  const [optimisticCount, addOptimisticLike] = useOptimistic(
    count,
    (current, delta) => current + delta
  );

  function handleLike() {
    startTransition(async () => {
      addOptimisticLike(1);
      const newCount = await sendLike();
      setCount(newCount);
    });
  }

  return <button onClick={handleLike}>👍 {optimisticCount}</button>;
}

The button renders optimisticCount, which increments immediately when clicked. The count state only updates after the server confirms. If sendLike throws, the transition ends, and optimisticCount reverts to the current count value .

The updater versus reducer pattern

The update function can be either an updater, similar to the function passed to setState, or a reducer that receives an action object. Updaters are sufficient when the optimistic value itself describes the change, such as incrementing a count. Reducers are necessary when the update depends on which item in a list is being modified, or when a single hook handles multiple types of updates .

// Updater: the optimistic value is the change.
const [count, addLike] = useOptimistic(count, (c, delta) => c + delta);
addLike(1);

// Reducer: the action describes the change.
const [items, dispatch] = useOptimistic(items, (current, action) => {
  switch (action.type) {
    case 'add': return [...current, action.item];
    case 'remove': return current.filter(i => i.id !== action.id);
    default: return current;
  }
});
dispatch({ type: 'add', item: newItem });

The reducer pattern is recommended when the base state might change while the Action is pending. If another update arrives, React re-runs the reducer with the new base state and the same action, ensuring the optimistic overlay reflects the latest data .

The requirement of Actions

The optimistic setter must be called inside an Action, which is a function passed to startTransition or an Action prop. If it is called outside this context, React does not know when the optimistic state should be cleared, and it will not automatically revert . The hook only resets when the surrounding transition completes.

// Correct: inside startTransition.
startTransition(async () => {
  addOptimistic(newItem);
  await saveItem(newItem);
});

// Incorrect: outside a transition.
addOptimistic(newItem); // Will not revert automatically.
await saveItem(newItem);

The rollback on error

When an Action throws, the transition ends. React renders with the base state, which the parent did not update because the server call failed. The optimistic state disappears, and the UI reverts to the last confirmed value. The developer can catch the error to show a message, but the rollback itself is automatic .

try {
  await addTodoAction(text);
} catch (err) {
  console.error('Failed:', err);
  // The optimistic item is already gone.
  // Show an error message if desired.
}

b. Common patterns

The hook supports several recurring patterns, each suited to a different kind of user interaction. The pattern determines the shape of the update function and the structure of the optimistic state.

Optimistic add to a list

Adding an item to a list is the most common optimistic pattern. The update function appends the new item to the current list, often with a pending flag so the UI can show a visual indicator .

const [optimisticTodos, addOptimisticTodo] = useOptimistic(
  todos,
  (currentTodos, newTodo) => [
    ...currentTodos,
    { ...newTodo, pending: true }
  ]
);

function handleAdd(text) {
  startTransition(async () => {
    addOptimisticTodo({ id: crypto.randomUUID(), text });
    await addTodoAction(text);
  });
}

Each optimistic item carries a pending: true flag. The UI renders pending items with a spinner, dimmed text, or a “Sending…” label. When the server confirms and the parent updates todos, the optimistic item is replaced by the confirmed item without the pending flag .

Optimistic toggle

A toggle, such as a Like button, inverts a boolean and adjusts a count. The update function computes both changes from the current state .

const [optimisticLike, setOptimisticLike] = useOptimistic(
  { liked: isLiked, count },
  (current) => ({
    liked: !current.liked,
    count: current.liked ? current.count - 1 : current.count + 1
  })
);

The isPending flag from useTransition should disable the button while the request is in flight to prevent double-clicks from dispatching contradictory optimistic updates .

Optimistic delete with rollback

Deleting an item optimistically removes it from the list immediately. If the server call fails, the item reappears because the base state was never changed .

const [optimisticItems, removeItem] = useOptimistic(
  items,
  (current, idToRemove) =>
    current.filter(item => item.id !== idToRemove)
);

function handleDelete(id) {
  startTransition(async () => {
    removeItem(id);
    await deleteAction(id);
  });
}

The developer catches the error to show a message, but the item’s return to the UI is automatic .

Optimistic inline editing

Inline editing merges partial updates into a record. The update function spreads the existing record and the changed fields, adding a pending flag .

const [optimisticRecord, setOptimisticRecord] = useOptimistic(
  record,
  (current, updatedFields) => ({
    ...current,
    ...updatedFields,
    pending: true
  })
);

function handleSave(field, value) {
  startTransition(async () => {
    setOptimisticRecord({ [field]: value });
    await updateRecord(record.id, { [field]: value });
  });
}

Client-side validation should run before dispatching the optimistic update, so the UI does not show an invalid state .

Multiple action types with a reducer

When a component supports several kinds of optimistic updates, such as a shopping cart that handles add, remove, and quantity changes, a reducer with an action object keeps the logic organized .

const [optimisticCart, dispatch] = useOptimistic(
  cart,
  (currentCart, action) => {
    switch (action.type) {
      case 'add':
        return [...currentCart, { ...action.item, quantity: 1, pending: true }];
      case 'remove':
        return currentCart.filter(item => item.id !== action.id);
      case 'update_quantity':
        return currentCart.map(item =>
          item.id === action.id
            ? { ...item, quantity: action.quantity, pending: true }
            : item
        );
      default:
        return currentCart;
    }
  }
);

Each action type sets the pending flag so the UI can show per-item feedback. The reducer is re-run with the latest base state if the cart changes while an Action is pending .

Using useOptimistic without startTransition in Action props

When the optimistic update happens inside a function passed to an action prop, the function is already wrapped in a transition by convention. In this case, the optimistic setter can be called directly without an explicit startTransition .

async function submitAction(formData) {
  const newName = formData.get('name');
  setOptimisticName(newName);
  const updatedName = await updateName(newName);
  startTransition(() => {
    setName(updatedName);
  });
}

The distinction is subtle: the optimistic update happens before the await, and the canonical state update happens inside a startTransition after the await resolves .


c. Caveats and limitations

useOptimistic is a focused tool with specific constraints. Understanding those constraints prevents misuse.

The revert dependency on transitions

The optimistic state only reverts when the surrounding transition ends. If the optimistic setter is called outside a transition, it has no defined lifecycle. React cannot know when to clear the optimistic overlay. The hook is designed for use inside Actions, and using it elsewhere produces undefined behavior .

The stale updater problem

An updater function like setOptimistic(prev => [...prev, newItem]) only sees the base state from when the transition started. If the base state changes during the async work, the updater does not re-run. This is why the React documentation recommends the reducer pattern for list operations: the reducer is re-executed with the latest base state, ensuring the optimistic overlay is always applied on top of current data .

No external mutation

useOptimistic does not mutate the underlying state. It creates a temporary overlay. The canonical state is controlled by the parent component and should only be updated when the server confirms the operation. Attempting to use the optimistic state as the source of truth for the canonical state creates a disconnect between what the UI shows and what the server knows.

The pending flag is not automatic

The hook does not add a pending flag to optimistic items. The developer must include it in the update function if the UI needs to distinguish optimistic items from confirmed ones. This is a convention, not a built-in feature .

Action props naming convention

Functions passed to action props are assumed to be wrapped in a transition. The React documentation uses the naming convention of ending these props with “Action” to signal this. When a function is passed to an action prop, startTransition is not needed around the optimistic setter, but it is still needed around the canonical state update after the await .


Complete Example Session

// ============================================
// PART 1: THE PROBLEM — WAITING FOR THE SERVER
// ============================================

// A comment section that waits for server confirmation.
// The user types, clicks Post, and waits.

function SlowComments({ initialComments, postComment }) {
  const [comments, setComments] = useState(initialComments);
  const [isSubmitting, setIsSubmitting] = useState(false);

  async function handleSubmit(formData) {
    const text = formData.get('text');
    setIsSubmitting(true);
    const saved = await postComment(text);
    setComments(prev => [saved, ...prev]);
    setIsSubmitting(false);
  }

  return (
    <div>
      <form action={handleSubmit}>
        <input name="text" placeholder="Add a comment..." />
        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? 'Posting...' : 'Post'}
        </button>
      </form>
      <ul>
        {comments.map(c => <li key={c.id}>{c.text}</li>)}
      </ul>
    </div>
  );
}

// The comment appears only after the server responds.
// The user sees a disabled button and no new comment.


// ============================================
// PART 2: THE OPTIMISTIC APPROACH
// ============================================

import { useOptimistic, startTransition } from 'react';

function OptimisticComments({ comments, postCommentAction }) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (currentComments, newComment) => [
      { ...newComment, pending: true },
      ...currentComments
    ]
  );

  function handleSubmit(formData) {
    const text = formData.get('text');
    const tempId = crypto.randomUUID();
    const optimisticComment = { id: tempId, text, author: 'You' };

    startTransition(async () => {
      addOptimisticComment(optimisticComment);
      await postCommentAction(text);
    });
  }

  return (
    <div>
      <form action={handleSubmit}>
        <input name="text" placeholder="Add a comment..." />
        <button type="submit">Post</button>
      </form>
      <ul>
        {optimisticComments.map(c => (
          <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
            {c.text}
            {c.pending && <small> (Posting...)</small>}
          </li>
        ))}
      </ul>
    </div>
  );
}

// The comment appears immediately with a pending flag.
// The base `comments` state is unchanged.
// When the Action completes, the optimistic overlay disappears.


// ============================================
// PART 3: THE LIKE TOGGLE
// ============================================

function LikeButton({ postId, initialLikes, isLiked, toggleLikeAction }) {
  const [likes, setLikes] = useState(initialLikes);
  const [liked, setLiked] = useState(isLiked);
  const [isPending, startTransition] = useTransition();

  const [optimisticLike, setOptimisticLike] = useOptimistic(
    { likes, liked },
    (current) => ({
      liked: !current.liked,
      likes: current.liked ? current.likes - 1 : current.likes + 1
    })
  );

  function handleClick() {
    startTransition(async () => {
      setOptimisticLike(null);
      const result = await toggleLikeAction(postId);
      setLikes(result.likes);
      setLiked(result.liked);
    });
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      {optimisticLike.liked ? '❤️' : '🤍'} {optimisticLike.likes}
    </button>
  );
}

// The button toggles immediately on click.
// isPending disables the button to prevent double-clicks.
// The optimistic state reverts automatically if the Action fails.


// ============================================
// PART 4: THE SHOPPING CART WITH A REDUCER
// ============================================

function ShoppingCart({ cart, cartActions }) {
  const [optimisticCart, dispatch] = useOptimistic(
    cart,
    (currentCart, action) => {
      switch (action.type) {
        case 'add': {
          const exists = currentCart.find(i => i.id === action.item.id);
          if (exists) {
            return currentCart.map(i =>
              i.id === action.item.id
                ? { ...i, quantity: i.quantity + 1, pending: true }
                : i
            );
          }
          return [...currentCart, { ...action.item, quantity: 1, pending: true }];
        }
        case 'remove':
          return currentCart.filter(i => i.id !== action.id);
        case 'update_quantity':
          return currentCart.map(i =>
            i.id === action.id
              ? { ...i, quantity: action.quantity, pending: true }
              : i
          );
        default:
          return currentCart;
      }
    }
  );

  function handleAdd(item) {
    startTransition(async () => {
      dispatch({ type: 'add', item });
      await cartActions.add(item);
    });
  }

  function handleRemove(id) {
    startTransition(async () => {
      dispatch({ type: 'remove', id });
      await cartActions.remove(id);
    });
  }

  return (
    <ul>
      {optimisticCart.map(item => (
        <li key={item.id} style={{ opacity: item.pending ? 0.5 : 1 }}>
          {item.name} x {item.quantity}
          {item.pending && ' (Saving...)'}
          <button onClick={() => handleRemove(item.id)}>Remove</button>
        </li>
      ))}
    </ul>
  );
}

// One hook handles three action types.
// Each action sets a pending flag for visual feedback.
// The reducer re-runs if the cart changes during a pending Action.


// ============================================
// PART 5: ERROR HANDLING AND ROLLBACK
// ============================================

function TodoList({ todos, addTodoAction }) {
  const [error, setError] = useState(null);
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos, newTodo) => [
      ...currentTodos,
      { ...newTodo, pending: true }
    ]
  );

  async function handleAdd(formData) {
    const text = formData.get('text');
    setError(null);
    addOptimisticTodo({ id: crypto.randomUUID(), text });

    try {
      await addTodoAction(text);
    } catch (err) {
      setError('Failed to add todo. Please try again.');
      // The optimistic todo is already gone.
      // The base state was never updated.
    }
  }

  return (
    <div>
      {error && <p role="alert" style={{ color: 'red' }}>{error}</p>}
      <form action={handleAdd}>
        <input name="text" placeholder="New todo..." />
        <button type="submit">Add</button>
      </form>
      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
            {todo.text}
            {todo.pending && ' (Adding...)'}
          </li>
        ))}
      </ul>
    </div>
  );
}

// On failure, the optimistic item disappears automatically.
// The error message informs the user.
// The canonical todos list is unchanged.


// ============================================
// PART 6: ACTION PROP WITHOUT startTransition
// ============================================

function EditName({ name, action }) {
  const [optimisticName, setOptimisticName] = useOptimistic(name);

  async function submitAction(formData) {
    const newName = formData.get('name');
    setOptimisticName(newName);
    const updatedName = await updateName(newName);
    startTransition(() => {
      action(updatedName);
    });
  }

  return (
    <form action={submitAction}>
      <p>Your name is: {optimisticName}</p>
      <input type="text" name="name" defaultValue={name} />
      <button type="submit">Save</button>
    </form>
  );
}

// The action prop is already wrapped in a transition.
// setOptimisticName can be called directly.
// The canonical state update after the await needs startTransition.


// ============================================
// PART 7: THE REDUCER ADVANTAGE
// ============================================

// A scenario where the base list changes during an optimistic add.

// Component A adds "Task 1" optimistically.
// While the request is pending, Component B adds "Task 2".
// The base `todos` prop now contains Task 2.

// With an updater:
// setOptimistic(prev => [...prev, Task 1])
// prev is the list from when the transition started.
// Task 2 is not in prev.
// Result: optimistic UI shows [Task 1], missing Task 2.

// With a reducer:
// reducer(currentTodos, action)
// currentTodos is the latest base state.
// React re-runs the reducer with the updated list.
// Result: optimistic UI shows [Task 1, Task 2].


// ============================================
// PART 8: COMMON MISTAKES
// ============================================

// Mistake 1: Calling the setter outside a transition.
addOptimisticTodo(newTodo);  // ❌ Will not revert.

// Mistake 2: Using the optimistic state as canonical.
const [optimistic, setOptimistic] = useOptimistic(realState);
// Using `optimistic` as the source of truth for other logic.  // ❌

// Mistake 3: Forgetting the pending flag.
(current, newItem) => [...current, newItem]  // ❌ No visual feedback.

// Mistake 4: Not disabling the action while pending.
<button onClick={handleLike}>Like</button>  // ❌ Double-click race.

// Mistake 5: Mutating the base state.
(current, newItem) => {
  current.push(newItem);  // ❌ Mutates the base state.
  return current;
}


// ============================================
// PART 9: COMBINING WITH useActionState
// ============================================

// useOptimistic handles immediate feedback.
// useActionState handles form state and errors.

import { useActionState, useOptimistic } from 'react';

function CommentForm({ comments, postCommentAction }) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (current, newComment) => [...current, { ...newComment, pending: true }]
  );

  const [state, formAction, isPending] = useActionState(
    async (prevState, formData) => {
      const text = formData.get('text');
      addOptimisticComment({ id: crypto.randomUUID(), text });
      try {
        await postCommentAction(text);
        return { error: null };
      } catch (err) {
        return { error: 'Failed to post comment.' };
      }
    },
    { error: null }
  );

  return (
    <div>
      <form action={formAction}>
        <input name="text" />
        <button type="submit" disabled={isPending}>Post</button>
      </form>
      {state.error && <p role="alert">{state.error}</p>}
      <ul>
        {optimisticComments.map(c => (
          <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
            {c.text}
          </li>
        ))}
      </ul>
    </div>
  );
}


// ============================================
// PART 10: WHEN TO USE useOptimistic
// ============================================

// USE useOptimistic FOR:
// - Adding items to a list (comments, todos, messages)
// - Toggling state (likes, favorites, follows)
// - Inline editing with immediate feedback
// - Shopping cart operations
// - Any action where the expected outcome is predictable

// DO NOT USE useOptimistic FOR:
// - Actions where the result is unknown (e.g., "generate AI response")
// - Operations that need server-computed values
// - Form submissions that navigate or reload
// - Cases where a loading spinner is sufficient
// - State that does not depend on server confirmation

The ten parts progressed from the problem of server-dependent UI through the basic optimistic pattern, toggle and list patterns, the reducer advantage, error handling, action prop usage, common mistakes, combination with useActionState, and a decision guide. The same comment, like, and todo scenarios ran through every part, showing how the hook provides immediate feedback while preserving correctness.


Quick Reference

useOptimistic API

ParameterTypeDescription
stateAnyThe canonical state from parent or props
updateFn(current, value) => newStateOptional function computing optimistic state
Returns[optimisticState, addOptimistic]Current optimistic state and dispatch function

Update Function Patterns

PatternUse CaseExample
UpdaterSimple value change(current, delta) => current + delta
ReducerMultiple action types(current, action) => { switch ... }
AppendAdd to list(current, item) => [...current, item]
ToggleBoolean flip(current) => !current
Partial mergeInline editing(current, fields) => ({ ...current, ...fields })

When to Use Which Pattern

ScenarioPatternReason
Increment counterUpdaterValue describes the change
Add to listReducerBase may change during pending
Multiple update typesReducerSingle hook handles all
Toggle booleanUpdaterSimple inversion
Edit record fieldsUpdaterMerge partial updates

Rollback Behavior

OutcomeWhat Happens
Action succeedsOptimistic state reverts; base state updated by parent
Action failsOptimistic state reverts; base state unchanged
Action pendingOptimistic state shown; base state hidden
No transitionOptimistic state never reverts

Best Practices

✅ Do This:

// Call the optimistic setter inside startTransition.
startTransition(async () => {
  addOptimistic(item);  // ✅
  await saveItem(item);
});

// Use a reducer for list operations.
const [items, dispatch] = useOptimistic(baseItems, reducer);  // ✅

// Add a pending flag for visual feedback.
(current, item) => [...current, { ...item, pending: true }];  // ✅

// Disable the action while pending.
<button disabled={isPending}>Like</button>  // ✅

// Handle errors to show a message.
try { await save(); } catch (e) { setError(e.message); }  // ✅

// Keep the optimistic state separate from canonical state.
// The parent owns the canonical state.  // ✅

// Use crypto.randomUUID() for temporary IDs.
{ id: crypto.randomUUID(), text }  // ✅

❌ Don’t Do This:

// Don't call the setter outside a transition.
addOptimistic(item);  // ❌ Never reverts

// Don't mutate the base state.
(current, item) => { current.push(item); return current; }  // ❌

// Don't use updater for list operations with concurrent updates.
(current, item) => [...current, item];  // ❌ May miss concurrent changes

// Don't forget the pending flag.
(current, item) => [...current, item];  // ❌ No visual feedback

// Don't allow double-clicks during pending.
<button onClick={handleClick}>Like</button>  // ❌ Race condition

// Don't use optimistic state as canonical source.
// Always use the base state for server operations.  // ❌

// Don't expect the hook to work without an Action.
// It must be inside startTransition or an action prop.  // ❌

Common Pitfalls

PitfallWhy It HappensFix
Optimistic state never revertsSetter called outside transitionWrap in startTransition or Action prop
Concurrent updates lostUpdater used instead of reducerUse reducer for list operations
No visual feedbackPending flag missingAdd pending: true in update function
Double-click raceButton not disabled during pendingUse isPending from useTransition
Base state mutatedUpdate function mutates arrayReturn new arrays, never mutate
Rollback doesn’t happenAction error not caughtThe hook reverts automatically; catch to show error
Hook has no effectCalled outside componentCall at top level of component
Stale optimistic dataReducer not usedReducer re-runs with latest base state

Real-World Examples

1. Optimistic Comment Addition

const [optimisticComments, addComment] = useOptimistic(
  comments,
  (current, newComment) => [{ ...newComment, pending: true }, ...current]
);

2. Optimistic Like Toggle

const [optimisticLike, setLike] = useOptimistic(
  { liked, count },
  (current) => ({
    liked: !current.liked,
    count: current.liked ? current.count - 1 : current.count + 1
  })
);

3. Optimistic Todo Delete

const [optimisticTodos, removeTodo] = useOptimistic(
  todos,
  (current, id) => current.filter(t => t.id !== id)
);

4. Optimistic Shopping Cart Add

const [optimisticCart, dispatch] = useOptimistic(
  cart,
  (current, action) => {
    if (action.type === 'add') {
      const exists = current.find(i => i.id === action.item.id);
      if (exists) {
        return current.map(i =>
          i.id === action.item.id
            ? { ...i, quantity: i.quantity + 1, pending: true }
            : i
        );
      }
      return [...current, { ...action.item, quantity: 1, pending: true }];
    }
    return current;
  }
);

5. Optimistic Inline Edit

const [optimisticRecord, setRecord] = useOptimistic(
  record,
  (current, fields) => ({ ...current, ...fields, pending: true })
);

6. Combined with useActionState

const [state, formAction, isPending] = useActionState(
  async (prev, formData) => {
    addOptimistic(formData.get('text'));
    try {
      await save(formData.get('text'));
      return { error: null };
    } catch (e) {
      return { error: e.message };
    }
  },
  { error: null }
);

7. Action Prop Without startTransition

async function submitAction(formData) {
  setOptimisticName(formData.get('name'));
  const updated = await updateName(formData.get('name'));
  startTransition(() => action(updated));
}

8. Pending Flag for Individual Items

{optimisticItems.map(item => (
  <li key={item.id} style={{ opacity: item.pending ? 0.5 : 1 }}>
    {item.text}
    {item.pending && ' (Saving...)'}
  </li>
))}

9. Preventing Double-Click Race

<button disabled={isPending} onClick={handleLike}>Like</button>

10. Reducer Re-Run on Concurrent Update

// If the base list changes during pending, React re-runs the reducer
// with the new list, ensuring the optimistic item is on top of latest data.

Visual

The Optimistic Lifecycle

┌────────────────────────────────────────────────────────────┐
│  OPTIMISTIC LIFECYCLE                                      │
│                                                            │
│  Initial state: base = [A, B]                             │
│                                                            │
│  User clicks "Add C".                                     │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  startTransition(async () => {                     │     │
│  │    addOptimistic(C);                              │     │
│  │    await save(C);                                 │     │
│  │  });                                              │     │
│  └──────────────────────────────────────────────────┘     │
│                        │                                   │
│                        v                                   │
│  ┌──────────────────────────────────────────────────┐     │
│  │  RENDER 1 (optimistic)                            │     │
│  │  optimistic = [A, B, C]                           │     │
│  │  base = [A, B]                                    │     │
│  │  UI shows [A, B, C] with C marked pending.        │     │
│  └──────────────────────────────────────────────────┘     │
│                        │                                   │
│                        │  Server responds                 │
│                        v                                   │
│  ┌──────────────────────────────────────────────────┐     │
│  │  Parent updates base to [A, B, C].                │     │
│  │  RENDER 2 (confirmed)                             │     │
│  │  optimistic = [A, B, C] (base, no pending)        │     │
│  │  UI shows [A, B, C] with C confirmed.             │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  On error: base remains [A, B].                            │
│  RENDER 2 (rollback)                                        │
│  optimistic = [A, B]                                        │
│  UI shows [A, B]. C is gone.                                │
│                                                            │
└────────────────────────────────────────────────────────────┘

Reducer vs Updater with Concurrent Updates

┌────────────────────────────────────────────────────────────┐
│  REDUCER vs UPDATER WITH CONCURRENT UPDATES                │
│                                                            │
│  Base = [Task 1]                                          │
│                                                            │
│  User A adds "Task 2" optimistically.                     │
│  Transition starts.                                        │
│                                                            │
│  User B adds "Task 3" optimistically.                     │
│  Base changes to [Task 1, Task 3].                        │
│                                                            │
│  UPDATER                                                    │
│  ┌──────────────────────────────────────────────────┐     │
│  │  setOptimistic(prev => [...prev, Task 2])         │     │
│  │  prev = [Task 1] (snapshot at transition start)   │     │
│  │  Result: [Task 1, Task 2]                         │     │
│  │  Task 3 is missing!                               │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  REDUCER                                                    │
│  ┌──────────────────────────────────────────────────┐     │
│  │  reducer(current, action)                          │     │
│  │  current = [Task 1, Task 3] (latest base)         │     │
│  │  Result: [Task 1, Task 3, Task 2]                 │     │
│  │  Task 3 is preserved!                              │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The reducer re-runs with the latest base state,           │
│  ensuring optimistic additions sit on top of current data. │
│                                                            │
└────────────────────────────────────────────────────────────┘

useOptimistic with Action Props

┌────────────────────────────────────────────────────────────┐
│  ACTION PROP WITHOUT startTransition                       │
│                                                            │
│  <EditName name={name} action={setName} />                 │
│                                                            │
│  Inside EditName:                                          │
│  ┌──────────────────────────────────────────────────┐     │
│  │  const [optimisticName, setOptimisticName] =       │     │
│  │    useOptimistic(name);                            │     │
│  │                                                   │     │
│  │  async function submitAction(formData) {           │     │
│  │    setOptimisticName(newName);  // No transition!  │     │
│  │    const updated = await updateName(newName);      │     │
│  │    startTransition(() => {                         │     │
│  │      action(updated);  // Canonical update         │     │
│  │    });                                             │     │
│  │  }                                                 │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The action prop is already wrapped in a transition        │
│  by the parent. So setOptimisticName can be called         │
│  directly. The canonical update after the await still      │
│  needs startTransition.                                    │
│                                                            │
└────────────────────────────────────────────────────────────┘

Common Patterns Summary

┌────────────────────────────────────────────────────────────┐
│  OPTIMISTIC PATTERNS AT A GLANCE                           │
│                                                            │
│  ADD TO LIST                                               │
│    [...current, { ...item, pending: true }]                │
│                                                            │
│  TOGGLE                                                    │
│    (current) => ({                                         │
│      liked: !current.liked,                                │
│      count: current.liked ? current.count - 1              │
│                          : current.count + 1               │
│    })                                                      │
│                                                            │
│  DELETE                                                    │
│    current.filter(i => i.id !== id)                        │
│                                                            │
│  PARTIAL MERGE                                             │
│    { ...current, ...fields, pending: true }                │
│                                                            │
│  MULTI-ACTION REDUCER                                      │
│    switch (action.type) {                                  │
│      case 'add': ...                                       │
│      case 'remove': ...                                    │
│      case 'update': ...                                    │
│    }                                                       │
│                                                            │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
useOptimisticHook for temporary optimistic state during Actions
Parametersstate (canonical), updateFn (optional)
Returns[optimisticState, addOptimistic]
LifecycleShows optimistic state during Action, reverts on completion
RequirementMust be called inside a transition or Action prop
RollbackAutomatic when Action fails
Reducer patternRe-runs with latest base state for concurrent updates
Pending flagDeveloper-added for visual feedback
Error handlingCatch to show message; rollback is automatic
Common usesAdds, toggles, deletes, inline edits, carts

Key takeaways:

  • useOptimistic shows a temporary state while an Action is pending. It does not replace the canonical state; it overlays it. When the Action completes, the overlay disappears, and the UI renders the truth that the server confirmed.
  • The hook only works inside a transition. The optimistic state reverts when the surrounding transition ends. Calling the setter outside an Action leaves the optimistic state orphaned with no revert lifecycle.
  • The reducer pattern handles concurrent updates. If the base state changes during a pending Action, an updater function sees stale data. A reducer re-runs with the latest base state, ensuring optimistic additions sit on top of current truth.
  • Rollback is automatic, not manual. If the Action throws, the transition ends, and the base state is unchanged. The optimistic overlay disappears without any explicit rollback logic.
  • The pending flag is a convention, not a feature. The hook does not mark optimistic items. The developer adds pending: true to the update function and renders the visual indicator accordingly.
  • Actions wrap the optimistic update and the server call together. startTransition(async () => { addOptimistic(x); await save(x); }) ensures the optimistic state exists for exactly the duration of the server operation.
  • Action props already include the transition. Functions passed to action props are wrapped in transitions by convention. The optimistic setter can be called directly without an extra startTransition.
  • Disable actions while pending to prevent races. Rapid clicks on a toggle can dispatch contradictory optimistic updates. The isPending flag from useTransition gates the action.

Remember: useOptimistic is not a state manager. It is a temporary overlay that exists for the duration of an async Action. It shows the user what they expect to happen, then yields to the real state the server confirms. The hook is designed for the Actions system: wrap the optimistic update and the server call in a transition, and the lifecycle handles itself. Use a reducer when the base state might change during the pending window. Add a pending flag for visual feedback. Disable the action to prevent double-clicks. Catch errors to show a message. The optimistic state reverts automatically on failure. The result is a UI that feels instantaneous without lying to the user, because the optimistic state is always temporary and always reconciles with the server’s truth.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!