A favourite button is a useful place to start thinking about optimistic UI. Change the icon immediately, send the update, and restore the old value if the request fails.
That works for a single operation. Repeated input makes the contract more interesting.
Imagine this sequence:
- The item starts unfavourited.
- The user favourites it. Request A starts.
- The user removes the favourite. Request B starts.
- The user favourites it again. Request C starts.
- B and C succeed.
- A fails, and its error handler restores the original unfavourited state.
The old snapshot now disagrees with the latest choice. A stale rollback has overwritten a newer successful action. The broader problem is applying an operation's historical state to the current interface.
Model three separate things
I find it useful to distinguish:
- Confirmed state: the latest authoritative state the client knows.
- Desired state: what the user currently wants.
- Pending operations: the requests attempting to reconcile them.
An operation's failure should not automatically become a command to restore the whole interface. First ask whether the operation still represents the user's current intent.
Here is a tiny, runnable example of the client-side relevance check. It deliberately models one control; it is not a complete network implementation:
let latestOperation = 0;
let desired = false;
function begin(nextValue) {
const operation = ++latestOperation;
const previous = desired;
desired = nextValue;
return {
fail() {
if (operation === latestOperation) {
desired = previous;
}
},
};
}
const first = begin(true);
begin(false);
begin(true);
first.fail();
console.log(desired); // true: an old failure did not replace newer intent
The check prevents an obsolete failure from changing desired. It does not prove the server has the right value. It also does not solve what to do if several operations fail: restoring an earlier desired value may restore a value that was never confirmed.
That limitation is why I would not paste this into a production favourite button and call the problem solved.
Choose a concurrency policy
For one item, serializing changes can make the contract simpler. An app can disable input until the request finishes, or keep collecting the latest desired state while allowing only one request at a time.
If it keeps collecting intent, the coordinator can compare the new confirmed value with the latest desired value after each response and decide whether another request is needed. A failed request still needs a defined retry or error policy.
If requests are allowed to overlap, the API needs a rule too. Sending “set favourite to true” communicates intent more clearly than “toggle,” but absolute values alone do not prevent older writes from arriving last. Versions, conditional writes, or server-side sequencing may be needed.
This is not specific to React. A hook, mutation library, or store can manage the mechanics, but the product still needs to choose what repeated input means.
Test the inconvenient order
I would include at least these scenarios:
- An earlier request fails after a later request succeeds.
- Responses arrive in the opposite order from the clicks.
- Every pending request fails.
- The response is lost after the server commits the write.
- Another tab changes the same item.
Check the displayed state and the persisted state separately. A correct icon can mask an incorrect server value, and the next reload will expose it.
The right policy depends on the action. Saving a marketplace item may justify quick repeated interaction. Approving a business record may justify blocking input and requiring a fresh review after a conflict.
My preference is to start with the simplest explicit policy that fits the action, then add concurrency only when it improves the experience enough to justify the extra states.
How do you handle repeated optimistic actions on the same item: block, serialize, coalesce, or overlap?
Top comments (0)