Optimistic UI shows a predicted result the instant a user acts — before the server confirms it — then reconciles to the real value once the update settles. You tap "like" and the count jumps immediately; if the server later reports a different number, the UI quietly corrects itself. Your job is to implement useOptimistic, a React hook modeled on React 19's useOptimistic, that layers a temporary optimistic value on top of a real one and clears it automatically once the real value catches up.
The hook takes the real, committed state and an updateFn, and returns the value to render plus a function to apply an optimistic update. While an optimistic input is pending, the returned value is updateFn(state, input). The moment the real state you pass in changes — the server responded, the caller committed — the optimistic layer is discarded and you show the real state again.
useOptimistic<State, Input>(
state: State, // the real, committed value (passed in each render)
updateFn: (current: State, input: Input) => State // pure: fold an optimistic input onto the current value
): [
optimisticState: State, // what to render: state, or the overlay while pending
addOptimistic: (input: Input) => void // apply an optimistic update; identity is stable
];
// Base state is 40 likes. The user taps like.
const [likes, addLike] = useOptimistic(40, (current, delta) => current + delta);
likes; // 40 — nothing pending, shows the real value
addLike(1); // apply an optimistic +1
likes; // 41 — the guess, shown immediately (updateFn(40, 1))
// The real state commits to 42 (the server's authoritative count). Re-render
// with the new base; the optimistic overlay is discarded automatically.
const [likes] = useOptimistic(42, (current, delta) => current + delta);
likes; // 42 — NOT the stale 41 guess. The overlay cleared on its own.
optimisticState is computed from state plus any pending input at render time. Keeping it as its own useState is the trap: it never reconciles.state prop changes, drop the overlay and show the new base. This is the reconciliation trigger you must implement.updateFn is pure — it takes the current value and an input and returns the next value, without mutating its arguments.addOptimistic more than once layers the inputs in order, each folded onto the previous overlay.addOptimistic — its identity does not change across re-renders, so it is safe in an effect dependency array or a memoized child.state prop; the reconcile watches that prop.You'll build a hook that layers a predicted value on top of a real one, shows the prediction the instant a user acts, and clears it the moment the real value arrives.
Your user taps "like" on a post that has 40 likes. Waiting for the server before the number moves feels broken — so you show 41 right away, as a guess. A moment later the server responds with the authoritative count (say 42, because someone else liked it too), and the UI should quietly become 42. The guess was always temporary; the real value wins. The whole trick is making that hand-off automatic, so the UI never gets stuck showing 41 after the truth arrives.
There are two values, not one. The real value is the state you pass in — the last thing the server confirmed. On top of it sits a transient overlay: a pending optimistic input, folded onto the real value by updateFn. While that input is pending, you render updateFn(state, input). The overlay is not a separate copy you keep in sync — it is recomputed from the real state on every render, so the instant state changes, the overlay is rebuilt against the new base and the old guess is gone.
The hook has to show the guess immediately, so the obvious move is to keep the optimistic value in its own state and move it when addOptimistic is called:
function useOptimistic(state, updateFn) {
const [optimistic, setOptimistic] = useState(state);
const addOptimistic = (input) => {
setOptimistic((current) => updateFn(current, input));
};
return [optimistic, addOptimistic];
}
This shows 41 the instant you call addOptimistic(1) — so far so good. But it never reconciles. useState(state) reads its argument on the first render only; every render after that ignores it. So when the real state prop later commits to 42, optimistic is still holding 41, and the UI keeps showing the stale guess. You've made the optimistic value a second source of truth, and the two sources drift apart the moment the server answers.
The fix is to stop storing the optimistic value and start deriving it. Keep the pending inputs, fold them over the real state at render time, and reset them whenever state changes:
const { useState, useCallback } = require('react');
function useOptimistic(state, updateFn) {
// The pending optimistic inputs (oldest first). We store the raw INPUTS — not
// the optimistic value — and fold them over the real `state` at render time.
const [pending, setPending] = useState([]);
// The base `state` we last reconciled against. Comparing it to the current
// prop is how we notice that the real value has committed.
const [prevState, setPrevState] = useState(state);
// Reconcile during render: when the base `state` changes, the server has
// responded (or the caller committed), so the optimistic overlay is stale —
// drop it. This is React's supported "adjust state when a prop changes" idiom;
// it re-renders once with the fresh value and never commits a stale frame.
if (!Object.is(prevState, state)) {
setPrevState(state);
setPending([]);
}
const addOptimistic = useCallback((input) => {
// Layer another input on top. A functional update so several calls in one
// action compose instead of clobbering each other.
setPending((current) => current.concat([input]));
}, []);
// The optimistic value is DERIVED, never stored: fold every pending input over
// the real `state`, in order, with the CURRENT updateFn. Nothing pending -> `state`.
const optimisticState = pending.reduce((acc, input) => updateFn(acc, input), state);
return [optimisticState, addOptimistic];
}
module.exports = { useOptimistic };
Two things changed. optimisticState is now a reduce over state, so it is rebuilt from the real value on every render — with nothing pending it is simply state. And the if (!Object.is(prevState, state)) block is the reconcile: when the base prop changes, we clear the pending inputs during render, which is React's documented way to adjust state when a prop changes. Because the value is derived and the pending layer resets on every commit, it is physically impossible to get stuck on a stale guess.
Start with useOptimistic(40, (n, delta) => n + delta) — a post with 40 likes.
pending is [] and prevState is 40. The guard Object.is(40, 40) is true, so nothing resets. optimisticState is [].reduce(..., 40), which is just 40. The UI shows 40.addOptimistic(1), which sets pending to [1]. On the re-render, state is still 40, so no reconcile. optimisticState is [1].reduce((acc, d) => acc + d, 40), which is updateFn(40, 1) = 41. The UI shows 41 immediately, while the request is still in flight.state now 42. This time Object.is(prevState = 40, state = 42) is false: the guard fires, sets prevState to 42, and clears pending to []. React re-renders once more with the fresh values.pending is [] and prevState is 42. optimisticState is [].reduce(..., 42) = 42. The overlay is gone and the UI shows 42 — the real value, not the discarded guess.React 19 ships useOptimistic(state, updateFn) with the same shape, but its reconcile is tied to the Action and Transition lifecycle: per the docs, "optimistic state only renders while an Action is in progress, otherwise value is rendered," and "the optimistic and real state converge in the same render when the Transition completes." Our userland version has no Actions, so it reconciles on a different trigger: it watches the base state prop you pass in and clears the overlay when that prop changes. The user-visible behavior — show the guess now, snap to the real value later — matches, but this is not byte-identical to the built-in. In particular, the built-in clears the overlay when the action ends even if the resulting state is unchanged, whereas this version keys off the state value changing (by Object.is). Reach for the real hook in production; build this one to understand what it does.
useState. It shows the guess but never reconciles — useState(state) reads state on the first render only, so once the server responds the UI is stuck on the old guess. Fix: derive the value from state every render, and keep only the pending inputs as state.useEffect([state]) instead of during render. An effect runs after the commit, so there is one painted frame where the overlay is folded onto the new base (the guess flickers on top of the real value) before the effect clears it. Resetting during render converges in one pass with no stale frame.=== on the wrong thing, or forgetting the guard resets prevState. The reconcile must both clear pending and advance prevState to the current state; if you clear pending without updating prevState, the guard fires forever and you loop. Advancing prevState is what makes the next render skip the branch.updateFn. Fold with the updateFn from the current render (call it directly in the reduce), not one captured once. If the reducer changes between renders, the overlay should reflect the new one.addOptimistic on updateFn. Wrapping it in useCallback([]) keeps its identity stable across renders even when the caller passes an inline updateFn. A dependency on updateFn would hand a memoized child a new function every render.updateFn and error rollback. Real optimistic UIs often need to revert on failure. Here, a failed request simply means the caller never commits a new state, so the overlay clears back to the unchanged base on the next render — but a dedicated manager that tracks per-mutation patches (see the Optimistic Mutation Manager question) handles concurrent failures more precisely.useOptimistic. Wire the same pattern to React's Actions with useTransition and an async action, and let React drive the reconcile off the transition completing instead of a prop change.state change reconciles all of them at once.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Optimistic UI shows a predicted result the instant a user acts — before the server confirms it — then reconciles to the real value once the update settles. You tap "like" and the count jumps immediately; if the server later reports a different number, the UI quietly corrects itself. Your job is to implement useOptimistic, a React hook modeled on React 19's useOptimistic, that layers a temporary optimistic value on top of a real one and clears it automatically once the real value catches up.
The hook takes the real, committed state and an updateFn, and returns the value to render plus a function to apply an optimistic update. While an optimistic input is pending, the returned value is updateFn(state, input). The moment the real state you pass in changes — the server responded, the caller committed — the optimistic layer is discarded and you show the real state again.
useOptimistic<State, Input>(
state: State, // the real, committed value (passed in each render)
updateFn: (current: State, input: Input) => State // pure: fold an optimistic input onto the current value
): [
optimisticState: State, // what to render: state, or the overlay while pending
addOptimistic: (input: Input) => void // apply an optimistic update; identity is stable
];
// Base state is 40 likes. The user taps like.
const [likes, addLike] = useOptimistic(40, (current, delta) => current + delta);
likes; // 40 — nothing pending, shows the real value
addLike(1); // apply an optimistic +1
likes; // 41 — the guess, shown immediately (updateFn(40, 1))
// The real state commits to 42 (the server's authoritative count). Re-render
// with the new base; the optimistic overlay is discarded automatically.
const [likes] = useOptimistic(42, (current, delta) => current + delta);
likes; // 42 — NOT the stale 41 guess. The overlay cleared on its own.
optimisticState is computed from state plus any pending input at render time. Keeping it as its own useState is the trap: it never reconciles.state prop changes, drop the overlay and show the new base. This is the reconciliation trigger you must implement.updateFn is pure — it takes the current value and an input and returns the next value, without mutating its arguments.addOptimistic more than once layers the inputs in order, each folded onto the previous overlay.addOptimistic — its identity does not change across re-renders, so it is safe in an effect dependency array or a memoized child.state prop; the reconcile watches that prop.You'll build a hook that layers a predicted value on top of a real one, shows the prediction the instant a user acts, and clears it the moment the real value arrives.
Your user taps "like" on a post that has 40 likes. Waiting for the server before the number moves feels broken — so you show 41 right away, as a guess. A moment later the server responds with the authoritative count (say 42, because someone else liked it too), and the UI should quietly become 42. The guess was always temporary; the real value wins. The whole trick is making that hand-off automatic, so the UI never gets stuck showing 41 after the truth arrives.
There are two values, not one. The real value is the state you pass in — the last thing the server confirmed. On top of it sits a transient overlay: a pending optimistic input, folded onto the real value by updateFn. While that input is pending, you render updateFn(state, input). The overlay is not a separate copy you keep in sync — it is recomputed from the real state on every render, so the instant state changes, the overlay is rebuilt against the new base and the old guess is gone.
The hook has to show the guess immediately, so the obvious move is to keep the optimistic value in its own state and move it when addOptimistic is called:
function useOptimistic(state, updateFn) {
const [optimistic, setOptimistic] = useState(state);
const addOptimistic = (input) => {
setOptimistic((current) => updateFn(current, input));
};
return [optimistic, addOptimistic];
}
This shows 41 the instant you call addOptimistic(1) — so far so good. But it never reconciles. useState(state) reads its argument on the first render only; every render after that ignores it. So when the real state prop later commits to 42, optimistic is still holding 41, and the UI keeps showing the stale guess. You've made the optimistic value a second source of truth, and the two sources drift apart the moment the server answers.
The fix is to stop storing the optimistic value and start deriving it. Keep the pending inputs, fold them over the real state at render time, and reset them whenever state changes:
const { useState, useCallback } = require('react');
function useOptimistic(state, updateFn) {
// The pending optimistic inputs (oldest first). We store the raw INPUTS — not
// the optimistic value — and fold them over the real `state` at render time.
const [pending, setPending] = useState([]);
// The base `state` we last reconciled against. Comparing it to the current
// prop is how we notice that the real value has committed.
const [prevState, setPrevState] = useState(state);
// Reconcile during render: when the base `state` changes, the server has
// responded (or the caller committed), so the optimistic overlay is stale —
// drop it. This is React's supported "adjust state when a prop changes" idiom;
// it re-renders once with the fresh value and never commits a stale frame.
if (!Object.is(prevState, state)) {
setPrevState(state);
setPending([]);
}
const addOptimistic = useCallback((input) => {
// Layer another input on top. A functional update so several calls in one
// action compose instead of clobbering each other.
setPending((current) => current.concat([input]));
}, []);
// The optimistic value is DERIVED, never stored: fold every pending input over
// the real `state`, in order, with the CURRENT updateFn. Nothing pending -> `state`.
const optimisticState = pending.reduce((acc, input) => updateFn(acc, input), state);
return [optimisticState, addOptimistic];
}
module.exports = { useOptimistic };
Two things changed. optimisticState is now a reduce over state, so it is rebuilt from the real value on every render — with nothing pending it is simply state. And the if (!Object.is(prevState, state)) block is the reconcile: when the base prop changes, we clear the pending inputs during render, which is React's documented way to adjust state when a prop changes. Because the value is derived and the pending layer resets on every commit, it is physically impossible to get stuck on a stale guess.
Start with useOptimistic(40, (n, delta) => n + delta) — a post with 40 likes.
pending is [] and prevState is 40. The guard Object.is(40, 40) is true, so nothing resets. optimisticState is [].reduce(..., 40), which is just 40. The UI shows 40.addOptimistic(1), which sets pending to [1]. On the re-render, state is still 40, so no reconcile. optimisticState is [1].reduce((acc, d) => acc + d, 40), which is updateFn(40, 1) = 41. The UI shows 41 immediately, while the request is still in flight.state now 42. This time Object.is(prevState = 40, state = 42) is false: the guard fires, sets prevState to 42, and clears pending to []. React re-renders once more with the fresh values.pending is [] and prevState is 42. optimisticState is [].reduce(..., 42) = 42. The overlay is gone and the UI shows 42 — the real value, not the discarded guess.React 19 ships useOptimistic(state, updateFn) with the same shape, but its reconcile is tied to the Action and Transition lifecycle: per the docs, "optimistic state only renders while an Action is in progress, otherwise value is rendered," and "the optimistic and real state converge in the same render when the Transition completes." Our userland version has no Actions, so it reconciles on a different trigger: it watches the base state prop you pass in and clears the overlay when that prop changes. The user-visible behavior — show the guess now, snap to the real value later — matches, but this is not byte-identical to the built-in. In particular, the built-in clears the overlay when the action ends even if the resulting state is unchanged, whereas this version keys off the state value changing (by Object.is). Reach for the real hook in production; build this one to understand what it does.
useState. It shows the guess but never reconciles — useState(state) reads state on the first render only, so once the server responds the UI is stuck on the old guess. Fix: derive the value from state every render, and keep only the pending inputs as state.useEffect([state]) instead of during render. An effect runs after the commit, so there is one painted frame where the overlay is folded onto the new base (the guess flickers on top of the real value) before the effect clears it. Resetting during render converges in one pass with no stale frame.=== on the wrong thing, or forgetting the guard resets prevState. The reconcile must both clear pending and advance prevState to the current state; if you clear pending without updating prevState, the guard fires forever and you loop. Advancing prevState is what makes the next render skip the branch.updateFn. Fold with the updateFn from the current render (call it directly in the reduce), not one captured once. If the reducer changes between renders, the overlay should reflect the new one.addOptimistic on updateFn. Wrapping it in useCallback([]) keeps its identity stable across renders even when the caller passes an inline updateFn. A dependency on updateFn would hand a memoized child a new function every render.updateFn and error rollback. Real optimistic UIs often need to revert on failure. Here, a failed request simply means the caller never commits a new state, so the overlay clears back to the unchanged base on the next render — but a dedicated manager that tracks per-mutation patches (see the Optimistic Mutation Manager question) handles concurrent failures more precisely.useOptimistic. Wire the same pattern to React's Actions with useTransition and an async action, and let React drive the reconcile off the transition completing instead of a prop change.state change reconciles all of them at once.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.