Sitemap
Tech Tonic

Articles on popular things like Node.js, Deno, Bun, etc.

Use Optimistic hook in React 19

6 min readMay 26, 2025

--

With React 19, the development experience becomes more fluid and user-centric, thanks in part to new hooks like useOptimistic. This hook addresses a long-standing challenge in frontend development—how to make interfaces feel fast and responsive when dealing with asynchronous updates, especially in form submissions, list modifications, or API-driven state transitions.

What Is useOptimistic?

The useOptimistic hook allows you to optimistically update the UI by predicting the final state of an asynchronous operation before it actually completes. Instead of waiting for a server response to reflect a change, your interface can immediately show the intended result, giving users instant feedback and a smoother experience.

Internally, useOptimistic works by layering a temporary optimistic state over the current actual state. When the async task finishes, the real state replaces or reconciles with the optimistic one. This helps bridge the perceptual gap between user actions and server acknowledgment.

Why is this needed?

Before useOptimistic, implementing such a feature often required writing complex custom logic—duplicating state management, manually rolling back changes on failure, and syncing server data. This led to bloated components and fragile UI behavior. The useOptimistic hook simplifies this pattern and makes it declarative.

Syntax overview

const [optimisticState, addOptimistic] = useOptimistic(
baseState,
(previousState, optimisticUpdate) => {
// return a new optimistic state
}
);

Parameters:

  1. baseState: The current, committed state from the server or authoritative data source. This should be a value you trust to be accurate, such as the result of a useState, useReducer, or fetched response.
  2. reducerFn(previousState, optimisticUpdate): A pure function that defines how each new optimistic update transforms the current optimistic state. It’s similar in spirit to a reducer in useReducer, but it's applied to maintain an ephemeral state layer over baseState.

Returns:

  • optimisticState – This is what should be rendered in the UI. It's a blend of the base state and any applied optimistic updates.
  • addOptimistic(updatePayload) – A function you call when you want to simulate a change instantly. It accepts a payload that reducerFn will use to compute the next optimistic state.

Internal flow

Here’s how useOptimistic behaves in practice:

  1. The UI renders using optimisticState.
  2. When a user triggers an action (e.g., form submission), you:
  • Call addOptimistic(payload) to show the optimistic change.
  • Fire off an async task to persist the change to a backend.
  • Once the task resolves, you update baseState to the server-confirmed state via setState, setTodos, or similar.

3. The optimistic layer is then reconciled or discarded.

This pattern ensures your interface stays fast and responsive even while waiting for real-world delays like API roundtrips.

Code sample 1: Basic counter

This example demonstrates a simple use case of incrementing a counter optimistically.

'use client'; // needed for client components

import { useOptimistic, useState } from 'react';

export default function Counter() {
const [count, setCount] = useState(0);
const [optimisticCount, addOptimistic] = useOptimistic(count, (prev, next) => prev + next);

const increment = async () => {
addOptimistic(1); // show immediately
await fakeAPICall(); // simulate server sync
setCount((c) => c + 1); // commit real state
};

return (
<div>
<p>Count: {optimisticCount}</p>
<button onClick={increment}>+1</button>
</div>
);
}

async function fakeAPICall() {
return new Promise((res) => setTimeout(res, 1000));
}
  • The UI shows the new count instantly.
  • The real state count is updated once the fake API call completes.
  • This structure mimics how real-time apps (like chat or notification systems) behave.

Code sample 2: Optimistic to-do list

Imagine a to-do list where new tasks are shown immediately after submission, even though the server response may take a second or two.

'use client';

import { useOptimistic, useState } from 'react';

export default function TodoList() {
const [todos, setTodos] = useState([{ id: 1, title: 'Learn React 19' }]);

const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(prev, next) => [...prev, next]
);

const handleAdd = async (title) => {
const newTodo = { id: Date.now(), title };
addOptimisticTodo(newTodo); // temporary optimistic view

const serverTodo = await saveToServer(newTodo);
setTodos((t) => [...t, serverTodo]); // update real state
};

return (
<div>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<button onClick={() => handleAdd('Write docs')}>Add Task</button>
</div>
);
}

async function saveToServer(todo) {
return new Promise((res) =>
setTimeout(() => res({ ...todo, id: Math.random() * 10000 }), 1500)
);
}
  • Optimistic updates prevent flickers or perceived lags in dynamic UIs.
  • The server may return a different id, so reconciling it back into the real state is crucial.
  • This pattern improves UX while retaining data integrity.

Code sample 3: Error handling & rollback

Optimistic updates should also handle failures gracefully. In this example, we simulate a rollback if the server call fails.

'use client';

import { useOptimistic, useState } from 'react';

export default function FriendRequest() {
const [status, setStatus] = useState('Not Sent');
const [optimisticStatus, addOptimisticStatus] = useOptimistic(status, (_, next) => next);

const sendRequest = async () => {
addOptimisticStatus('Sending...');
try {
await sendToServer(); // may fail
setStatus('Sent');
} catch {
setStatus('Not Sent'); // rollback on error
}
};

return (
<div>
<p>Status: {optimisticStatus}</p>
<button onClick={sendRequest}>Send Friend Request</button>
</div>
);
}

async function sendToServer() {
const shouldFail = Math.random() < 0.3;
return new Promise((res, rej) =>
setTimeout(() => (shouldFail ? rej() : res()), 1000)
);
}
  • Reflects the transient “Sending…” state immediately.
  • If the server fails, we rollback to the prior state.
  • Demonstrates how useOptimistic integrates with error boundaries and recovery flows.

Code Sample 4: Optimistic product rating

In this example, we optimistically update the average rating of a product as soon as the user submits a new rating.

'use client';

import { useOptimistic, useState } from 'react';

export default function ProductRating() {
const [ratingData, setRatingData] = useState({ total: 100, count: 25 });

const [optimisticRating, addOptimisticRating] = useOptimistic(
ratingData,
(prev, newRating) => {
const updatedTotal = prev.total + newRating;
const updatedCount = prev.count + 1;
return { total: updatedTotal, count: updatedCount };
}
);

const handleRate = async (value) => {
addOptimisticRating(value); // optimistic feedback
try {
await submitRatingToServer(value);
setRatingData((prev) => ({
total: prev.total + value,
count: prev.count + 1,
}));
} catch {
// Handle rollback or notify user
}
};

const average = (optimisticRating.total / optimisticRating.count).toFixed(1);

return (
<div>
<p>Average Rating: {average}</p>
<button onClick={() => handleRate(5)}>Rate 5 Stars</button>
</div>
);
}

async function submitRatingToServer(value) {
return new Promise((res) => setTimeout(() => res(true), 1000));
}
  • This simulates instant feedback on a rating action.
  • The user sees the updated average immediately.
  • If the server fails, error handling or a silent rollback can be added to revert the UI.

Code sample 5: Optimistic shopping cart update

In a real-time e-commerce scenario, users expect immediate updates when adding items to a cart. This example shows how to use useOptimistic to manage that.

'use client';

import { useOptimistic, useState } from 'react';

export default function ShoppingCart() {
const [cart, setCart] = useState([{ id: '123', name: 'T-Shirt', qty: 1 }]);

const [optimisticCart, addOptimisticItem] = useOptimistic(
cart,
(prevCart, newItem) => {
const existing = prevCart.find((item) => item.id === newItem.id);
if (existing) {
return prevCart.map((item) =>
item.id === newItem.id ? { ...item, qty: item.qty + newItem.qty } : item
);
}
return [...prevCart, newItem];
}
);

const handleAddToCart = async () => {
const newItem = { id: '456', name: 'Cap', qty: 1 };
addOptimisticItem(newItem);

try {
await fakeCartAPI(newItem);
setCart((prev) => {
const existing = prev.find((item) => item.id === newItem.id);
if (existing) {
return prev.map((item) =>
item.id === newItem.id ? { ...item, qty: item.qty + 1 } : item
);
}
return [...prev, newItem];
});
} catch {
// optional rollback logic
}
};

return (
<div>
<h3>Shopping Cart</h3>
<ul>
{optimisticCart.map((item) => (
<li key={item.id}>
{item.name} - Qty: {item.qty}
</li>
))}
</ul>
<button onClick={handleAddToCart}>Add Cap</button>
</div>
);
}

async function fakeCartAPI(item) {
return new Promise((res) => setTimeout(() => res(true), 1000));
}
  • Demonstrates how to merge new items or update quantities optimistically.
  • Avoids UI flicker by updating the cart list instantly.
  • Allows for graceful degradation if the server response fails.

Best practices

  • Keep optimistic updates minimal: Only reflect what’s necessary. Overextending optimistic assumptions can cause jarring rollbacks.
  • Ensure reconciliation logic is robust: Sync real and optimistic states carefully, especially when the server modifies data (e.g., timestamps, IDs).
  • Combine with suspense and error boundaries: For a seamless experience in server-heavy apps, pair useOptimistic with use() and suspense.

When not to use it

  • Critical state updates: For banking, transactions, or sensitive operations, avoid assuming success.
  • Highly unpredictable server behavior: If the outcome is frequently different from the input, optimistic updates may degrade UX rather than improve it.

Thanks for reading!

--

--

Tech Tonic
Tech Tonic

Published in Tech Tonic

Articles on popular things like Node.js, Deno, Bun, etc.

No responses yet