DEV Community

Cover image for The bug took two days to find. The fix was one line. But we are solving the wrong problem
Bishoy Bishai
Bishoy Bishai

Posted on • Edited on • Originally published at bishoy-bishai.github.io

The bug took two days to find. The fix was one line. But we are solving the wrong problem

Let me tell you about a bug that took me two full days to find at a company I worked at.

A user on an internal support panel would click between two tickets quickly, ticket A, then ticket B, before ticket A finished loading. Most of the time, this worked fine. Occasionally, ticket B's page would render ticket A's data. Not crash. Not error. Just silently wrong. The kind of bug that makes a support agent send the wrong information to the wrong customer and nobody notices until someone complains.

I traced it for two days. It was a classic race condition: clicking ticket A fired a useEffect that called the API. Clicking ticket B before A resolved firing another `useEffect "for ticket B. If A's network request happened to resolve after B's because of network jitter, server load, or anything A's response would land last and overwrite B's data in state.

The fix was a few lines: an AbortController, a cleanup function, and a check for whether the effect was still "current." I'd written this exact pattern probably thirty times across my career by that point at nearly every company I'd worked for. Every single React codebase I'd touched needed this same defensive code, written by hand, in every single component that fetched data.

That was the moment I stopped being stubborn about React Query.

I'd avoided it for years. Partly because useEffect felt like "real React" and a data library felt like a crutch. Partly because I didn't understand what problem it was actually solving, I thought it was about reducing boilerplate, which felt like a nice-to-have, not a need-to-have. I was wrong on both counts, and the two-day bug is what finally made that clear.

The Category Error That Costs Everyone Time

Here's the mistake I made for years, and the mistake I see in almost every React codebase I review: treating server state as if it were client state.

Client state is data your application fully owns. A modal's open/closed status. The current step in a wizard. A dark mode preference. This state is synchronous, exclusive to your app, and there's exactly one source of truth: your component or your store.

Server state is fundamentally different. It's

  • Remote — it lives on a server you don't fully control
  • Shared — other users, other tabs, other devices might change it
  • Asynchronous by nature you're always looking at a snapshot, never the live truth
  • Potentially stale the instant you receive it — between the response leaving the server and rendering in your UI, the underlying data could have already changed

useState, useReducer, Redux, Zustand every general-purpose state tool was designed with client state's assumptions in mind: synchronous, single source of truth, fully owned by your application. When you use these tools for server state, you're fighting their fundamental assumptions, and that fight shows up as bugs.

The race condition I described isn't a `useEffect "bug" specifically. It's a category error: I used a tool built for synchronous, owned state to manage asynchronous, borrowed state. The tool doesn't know that a stale response can arrive after a fresh one. It doesn't know that the data might already be cached from another component. It doesn't know that "loading" needs to mean something different on first load versus on a background refetch. Because none of those concerns exist for client states, they only exist for server states.

React Query isn't a data-fetching convenience. It's a state management library built specifically for the category of state that useStatewas never designed to handle.


What the Two-Day Bug Actually Required to Fix Manually

Let me show you the honest, complete fix for the race condition, not the simplified version, the real one, because seeing the full defensive code is what makes the case for a dedicated tool.

// The "fixed" version, by hand, with useEffect
// This is what every data-fetching component needs to be correct.
// I've written approximately this exact code dozens of times.

function TicketDetail({ ticketId }: { ticketId: string }) {
  const [ticket, setTicket] = useState<Ticket | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // AbortController: cancel the in-flight request if ticketId changes
    // or the component unmounts before this resolves
    const controller = new AbortController();

    const fetchTicket = async () => {
      setIsLoading(true);
      setError(null);

      try {
        const response = await fetch(`/api/tickets/${ticketId}`, {
          signal: controller.signal,
        });

        if (!response.ok) throw new Error('Failed to fetch ticket');

        const data: Ticket = await response.json();
        setTicket(data);
      } catch (err) {
        // AbortError means we cancelled this on purpose — not a real error
        if (err instanceof Error && err.name === 'AbortError') return;
        setError(err instanceof Error ? err.message : 'Unknown error');
      } finally {
        setIsLoading(false);
      }
    };

    fetchTicket();

    return () => controller.abort();
  }, [ticketId]);

  // This component has NO caching. Click back to a ticket you already
  // viewed? Full refetch, full loading spinner, every time.

  // This component has NO background refresh. If the ticket status
  // changes on the server while this is open, you'll never know
  // unless you reload the page.

  // This component has NO deduplication. If TWO components on the
  // page both need this ticket's data, you fetch it TWICE.

  // This component has NO retry logic. One network blip and the
  // user sees an error, with no automatic recovery.

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage message={error} />;
  if (!ticket) return null;

  return <TicketView ticket={ticket} />;
}
Enter fullscreen mode Exit fullscreen mode

The AbortController fix solves the race condition. It does not solve caching. It does not solve background refresh. It does not solve deduplication across components. It does not solve retries. Each of those is its own multi-day investment to build correctly, and most teams never build them; they just live with stale data, duplicate requests, and unhandled network blips because building the infrastructure by hand for every feature is too expensive to justify.

This is the actual cost of treating server state as client state: not just the bugs you catch, but the entire category of correctness you never even attempt because it's too expensive to build manually.


The Same Component, Correctly, with React Query

// First, set up the QueryClient once at the app root
// queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Data is considered fresh for 30 seconds after fetching.
      // During this window, re-mounting the component or refocusing
      // the tab will NOT trigger a refetch — the cached data is trusted.
      staleTime: 30 * 1000,
      // Retry failed requests twice before giving up.
      // Network blips self-heal without the user noticing.
      retry: 2,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode
// services/tickets.ts
// The fetcher function — pure, framework-agnostic, easily testable

interface Ticket {
  id: string;
  subject: string;
  status: 'open' | 'pending' | 'closed';
  customerEmail: string;
  messages: Message[];
}

export async function fetchTicket(ticketId: string): Promise<Ticket> {
  const response = await fetch(`/api/tickets/${ticketId}`);
  if (!response.ok) {
    throw new Error(`Failed to fetch ticket: ${response.status}`);
  }
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode
// TicketDetail.tsx — the component, now genuinely simple

import { useQuery } from '@tanstack/react-query';
import { fetchTicket } from '../services/tickets';

function TicketDetail({ ticketId }: { ticketId: string }) {
  // The query key ['ticket', ticketId] is the cache identity.
  // Switch from ticket A to ticket B and back to A within 30 seconds?
  // Instant render from cache. No spinner. No refetch.
  const { data: ticket, isLoading, isError, error } = useQuery({
    queryKey: ['ticket', ticketId],
    queryFn: () => fetchTicket(ticketId),
  });

  // Race conditions: SOLVED. React Query tracks which query key is
  // "current" and discards responses for stale keys automatically.
  // Click A, then B fast — React Query knows B is the current request
  // and ignores a late-arriving response for A.

  // Caching: SOLVED. Revisit a ticket within staleTime — instant.

  // Deduplication: SOLVED. Ten components on the page asking for the
  // same ticketId? ONE network request. All ten get the same data.

  // Retries: SOLVED. Configured once, globally, applies everywhere.

  if (isLoading) return <Skeleton />;
  if (isError) return <ErrorMessage message={error.message} />;
  if (!ticket) return null;

  return <TicketView ticket={ticket} />;
}
Enter fullscreen mode Exit fullscreen mode

The component went from forty-plus lines of defensive plumbing to roughly ten lines of actual logic. But the real win isn't line count it's that four entire categories of bugs (race conditions, redundant fetches, stale-without-knowing, and unhandled transient failures) are now handled by infrastructure that's been battle-tested by thousands of production applications, instead of infrastructure I wrote myself at 11 PM under deadline pressure.


The Mental Model That Actually Matters: Stale-While-Revalidate

Here's the part most tutorials explain too quickly. Understanding this pattern is what separates "I added React Query" from "I understand why my app feels fast."

What happens when a component mounts and requests data
that's already in the cache:

Timeline:
0ms    Component mounts, calls useQuery(['ticket', '123'])
0ms    React Query checks cache: data EXISTS for this key
0ms    Returns cached data IMMEDIATELY — UI renders with content,
       no loading spinner, no flash, no delay
0ms    Is the cached data "stale" (older than staleTime)?
       If YES → React Query ALSO fires a background fetch
1ms    UI is fully rendered and interactive with cached data
300ms  Background fetch resolves
300ms  If the data CHANGED → re-render with fresh data
       If the data is THE SAME → no re-render, nothing visible happens
Enter fullscreen mode Exit fullscreen mode

This is the entire insight: the user never waits for a network request if there's any cached data available, even stale data. They see something immediately. If that something turns out to be outdated, it silently corrects itself a moment later usually before they'd notice anyway.

Compare this to the naive useEffect pattern, where every mount means a blank state, loading spinner, wait for network, then render. Stale-while-revalidate eliminates the wait in the common case (revisiting something you've already seen) and only pays the network cost once, in the background, invisibly.

// The staleTime vs gcTime distinction — the one configuration
// decision that determines your caching behavior

useQuery({
  queryKey: ['ticket', ticketId],
  queryFn: () => fetchTicket(ticketId),

  // staleTime: how long the data is considered "fresh"
  // During this window: cached data is served with ZERO background refetch
  // After this window: cached data is still served instantly,
  //   but a background refetch is triggered
  staleTime: 30 * 1000, // 30 seconds

  // gcTime (formerly cacheTime): how long UNUSED data stays in memory
  // after the last component using it unmounts
  // If you navigate away and come back within this window,
  // the cache is still there — instant render, even though stale
  gcTime: 5 * 60 * 1000, // 5 minutes (this is actually the default)
});
Enter fullscreen mode Exit fullscreen mode

The mistake almost everyone makes: setting staleTime: 0 (the default) for data that genuinely doesn't change often, causing unnecessary background refetches every time a component remounts. For a list of countries, a list of categories, anything that changes rarely — set staleTime to minutes or hours. For live, frequently-changing data — keep it low or zero. This single number is your most impactful performance lever, and most developers never touch it.


Mutations and the Cache Invalidation That Actually Works

Fetching is half the story. The other half: changing data and making sure your UI reflects the change everywhere it's displayed.

// services/tickets.ts

export async function updateTicketStatus(
  ticketId: string,
  status: Ticket['status']
): Promise<Ticket> {
  const response = await fetch(`/api/tickets/${ticketId}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ status }),
  });
  if (!response.ok) throw new Error('Failed to update ticket');
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode
// TicketActions.tsx

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateTicketStatus } from '../services/tickets';

function TicketActions({ ticket }: { ticket: Ticket }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (status: Ticket['status']) =>
      updateTicketStatus(ticket.id, status),

    onSuccess: (updatedTicket) => {
      // Option A: invalidate — tell React Query "this data might be stale,
      // refetch it in the background." Simple, always correct, slight delay.
      queryClient.invalidateQueries({ queryKey: ['ticket', ticket.id] });

      // Also invalidate any list views showing this ticket
      // (e.g., a ticket list filtered by status) — they need to know too
      queryClient.invalidateQueries({ queryKey: ['tickets'] });
    },
  });

  return (
    <div>
      <button
        onClick={() => mutation.mutate('closed')}
        disabled={mutation.isPending}
      >
        {mutation.isPending ? 'Closing...' : 'Close Ticket'}
      </button>
      {mutation.isError && (
        <p className="text-red-500">{mutation.error.message}</p>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Option B Optimistic Updates: when invalidation feels too slow

For interactions where instant feedback matters, like a button, a status toggle, or anything, the user expects to feel immediate invalidation, which introduces a visible delay: click, wait for the mutation, wait for the refetch, then see the update. Optimistic updates skip the wait.

function TicketStatusToggle({ ticket }: { ticket: Ticket }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (status: Ticket['status']) =>
      updateTicketStatus(ticket.id, status),

    // onMutate runs BEFORE the mutation fires — this is where
    // we update the cache optimistically
    onMutate: async (newStatus) => {
      // Cancel any in-flight refetches for this query so they
      // don't overwrite our optimistic update with stale data
      await queryClient.cancelQueries({ queryKey: ['ticket', ticket.id] });

      // Snapshot the current value — we need this to roll back on failure
      const previousTicket = queryClient.getQueryData<Ticket>(['ticket', ticket.id]);

      // Optimistically update the cache directly
      queryClient.setQueryData<Ticket>(['ticket', ticket.id], (old) =>
        old ? { ...old, status: newStatus } : old
      );

      // Return the snapshot — this becomes available in onError as `context`
      return { previousTicket };
    },

    // If the mutation fails, roll back using the snapshot
    onError: (err, newStatus, context) => {
      if (context?.previousTicket) {
        queryClient.setQueryData(['ticket', ticket.id], context.previousTicket);
      }
    },

    // Whether it succeeds or fails, refetch to ensure we're in sync
    // with the server's actual state
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['ticket', ticket.id] });
    },
  });

  return (
    <select
      value={ticket.status}
      onChange={(e) => mutation.mutate(e.target.value as Ticket['status'])}
    >
      <option value="open">Open</option>
      <option value="pending">Pending</option>
      <option value="closed">Closed</option>
    </select>
  );
}
Enter fullscreen mode Exit fullscreen mode

The user sees the dropdown change instantly. If the server rejects it with a permission error, validation failure, or network issue the dropdown silently reverts, and the cache eventually matches reality regardless of outcome.


Query Keys: The Decision That Determines Everything Else

I want to spend real time here because query keys are the single most consequential decision in any React Query implementation, and the documentation undersells how much thought they deserve.

The mental model: a query key is not a label. It's a dependency array for your data exactly like a `useEffect "dependency array," except React Query uses it to determine cache identity, not just when to refetch.

`typescript
// 🚩 The mistake: inconsistent or incomplete keys

// Component A
useQuery({ queryKey: ['tickets'], queryFn: () => fetchTickets() });

// Component B — fetching FILTERED tickets, but using the SAME key
useQuery({
queryKey: ['tickets'], // ❌ Same key, different actual data!
queryFn: () => fetchTickets({ status: 'open' }),
});

// Result: these two queries fight over the same cache slot.
// Whichever rendered LAST overwrites the cache for BOTH components.
// Component A might suddenly show only open tickets. Silent, confusing bug.
`

`typescript
// ✅ Correct: the key includes every variable that changes what's fetched

function useTickets(filters: { status?: string; assignee?: string }) {
return useQuery({
// The filters object is PART of the cache identity.
// Different filters → different cache entry → no collision.
queryKey: ['tickets', filters],
queryFn: () => fetchTickets(filters),
});
}

// Usage:
useTickets({ status: 'open' }); // Cache key: ['tickets', { status: 'open' }]
useTickets({ status: 'closed' }); // Cache key: ['tickets', { status: 'closed' }]
useTickets({ assignee: 'me' }); // Cache key: ['tickets', { assignee: 'me' }]
// All three cache independently. No collisions. No stale overwrite bugs.
`

The rule: every variable you use in queryFn to decide what to fetch must appear in the queryKey. If it's not in the key, React Query doesn't know it needs to treat that as a different cache entry, and you get exactly the silent data-swap bug I described at the start of this article, except now it's React Query causing it because you configured it wrong, not because you forgot an AbortController.


The Uncomfortable Truth About Adopting React Query

Here's what the "just add React Query, it's magic" articles don't say:

React Query doesn't remove the need to think about your data. It removes the need to write the same defensive plumbing by hand every time, badly.

The race condition bug I described doesn't disappear because you installed a library. It disappears because the library has already solved that exact problem, correctly, in a way that's been tested across thousands of production applications. But you still need to think about stale time. You still need to design your query keys correctly. You still need to decide between invalidation and optimistic updates for each interaction. The thinking moved from "How do I prevent race conditions?" to "What's the right caching strategy for this specific data?" which is a better question to be answering, but it's still a question that requires judgment.

Teams that adopt React Query and configure everything with defaults, never touching staleTime, never thinking about query key structure, get a worse outcome than teams that understood the defensive `useEffect "pattern" and wrote it carefully by hand. The library amplifies good judgment. It doesn't replace it.


✨ Let's keep the conversation going!

If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.

✍️ Read more on my blog: bishoy-bishai.github.io

Let's chat on LinkedIn: linkedin.com/in/bishoybishai

📘 Curious about AI?: You can also check out my book: Surrounded by AI

Top comments (0)