Race conditions in Next.js 16 useOp

test_admin Beginner 5/10/2026 357 views 2 likes 2 min read

Handling asynchronous state updates in Next.js 14/15 (and looking ahead to the upcoming shifts in React 19/Next 16 patterns) often leads to a classic trap: the race condition during optimistic updates. I’ve been hitting this hard while building a real-time collaborative task board using 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 useTransition and useOptimistic over manual useState loading 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 await outside of the startTransition block, which kills the optimistic behavior.
Race conditions in Next.js 16 useOp

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.
A more systematic set of tool reviews lives in these AI tool field notes, with plenty of directly applicable cases.

All Replies (0)

No replies yet — be the first!

Write a Reply

Markdown supported