Race conditions in Next.js 16 useOp
useOptimistic and Server Actions.The problem is simple but annoying. You trigger an action, the UI updates optimistically, but if the user clicks three different items in rapid succession, the server responses might return out of order. Because useOptimistic state is tied to the lifecycle of the pending action, a slow first request returning after a fast second request can snap your UI back to a stale state, causing that jarring "flicker" where the item jumps back and forth.
I found that the only way to reliably kill this is by implementing a client-side "request versioning" or a timestamp check inside the transition. Cursor helped me refactor this quickly, but the "magic" is in how you track the sequence.
Here is the pattern I'm using to stabilize the UI:
// hooks/useStableUpdate.ts
import { useOptimistic, useTransition } from 'react';
export function useStableUpdate<T>(initialState: T, updateFn: (state: T, value: any) => T) {
const [optimisticState, setOptimisticState] = useOptimistic(initialState, updateFn);
const [isPending, startTransition] = useTransition();
// Track the latest request ID to ignore stale server responses
const lastRequestId = useRef(0);
const execute = async (action: (id: number) => Promise<void>, payload: any) => {
const currentId = ++lastRequestId.current;
startTransition(async () => {
// Optimistic update happens immediately
setOptimisticState(payload);
try {
await action(currentId);
// If a newer request has already started, we don't manually
// trigger a refresh here to avoid fighting with the server state
} catch (e) {
console.error("Update failed", e);
}
});
};
return { optimisticState, execute, isPending };
}One major gotcha with useOptimistic is that it doesn't "cancel" the previous pending state if a new transition starts; it layers them. If you're doing complex array manipulations (like reordering a list), the optimistic state can get desynchronized from the actual server state if you don't handle the keys perfectly.
To optimize this in Cursor, I stopped asking it to "fix the bug" and started providing the specific React 19 RFC docs in the .cursorrules file. This prevents the AI from suggesting old useEffect cleanup patterns which are basically anti-patterns now that we have transitions.
My current config for handling these complex hooks:
- Rules file: Explicitly tell Cursor to prioritize
useTransitionanduseOptimisticover manualuseStateloading flags. - Prompting: Use "Refactor this to be race-condition proof using a sequence counter" rather than "Make this work."
- Verification: Always check if the AI is accidentally adding
awaitoutside of thestartTransitionblock, which kills the optimistic behavior.
The productivity gain here is massive. Instead of writing 50 lines of
useEffect and isLoading boilerplate, I've got a 15-line hook that handles the UI snap-back. The key is realizing that the server is the source of truth, but the client needs a "sequence number" to know which truth is the most recent.All Replies (0)
No replies yet — be the first!
