← Back to question bank
DebuggingSeniorHard#4010 · 35m

Debug custom useQuery hook under interview pressure

A React screen using custom useQuery hook passes simple tests but breaks during repeated interaction. Find the likely root cause, patch it, and describe the longer-term design improvement.

Answer Strategy

The broken pattern in this question is "useState + useEffect + fetch with no cancellation". On the happy path it works. As soon as a user switches the query rapidly — typeahead, filter clicks, route changes — older requests can resolve after newer ones, and the most recent setState writes a stale result on top of a fresh one. The fix is two correctness primitives: an AbortController per effect and an active flag that gates every state write.

Locate the boundary by asking "what is the latest user intent?". The dependency array of the effect is the answer; whenever it changes, any in-flight work from the previous render must be invalidated. AbortController.abort() handles network-level cancellation; the active flag handles the gap between abort and JS continuations because aborted promises still resolve on the microtask queue.

Adjacent traps: forgetting to gate the .catch path (an aborted fetch rejects with an AbortError; treating that as a real error sets state to "error" after a successful navigation), depending on a fetcher whose identity changes every render (the effect re-fires every commit; memoize at the call site), and storing the cache in component state (it disappears on unmount and refetches when the user navigates back).

Regression Fix: AbortController + Active Flag Per Effect

The fixed useQuery aborts the previous request and ignores stale resolutions when the dependency array changes.

// THE BUG: the original hook had no AbortController and no active flag.
// When the user changed search query rapidly, an older request resolved
// after a newer one and overwrote the state with stale data. The fix is
// per-effect cancellation plus an active flag that ignores resolutions
// from any outdated effect.

type Result<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

export function useQuery<T>(
  key: string,
  fetcher: (signal: AbortSignal) => Promise<T>
): Result<T> {
  const [state, setState] = React.useState<Result<T>>({ status: 'idle' });

  React.useEffect(() => {
    const controller = new AbortController();
    let active = true;
    setState({ status: 'loading' });

    fetcher(controller.signal)
      .then((data) => {
        if (!active) return;
        setState({ status: 'success', data });
      })
      .catch((error: Error) => {
        if (!active || controller.signal.aborted) return;
        setState({ status: 'error', error });
      });

    return () => {
      active = false;
      controller.abort();
    };
  }, [key, fetcher]);

  return state;
}

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Reproduce
Trigger two rapid key changes where the first request takes longer than the second. Without abort, the slow request lands last and the user sees the old data. The regression test asserts the final state matches the latest key.
Patch
Add AbortController + active flag. Test both legs: cancellation before resolution (abort fires, .catch sees AbortError, no setState) and resolution before cancellation (active=false, no setState).
Prevent
Add a lint rule against fetch without an AbortSignal in custom hooks. Pair with a smoke test that exercises rapid key changes against a real network gateway because the bug only surfaces under contention.
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';

describe('useQuery regression', () => {
  it('older request that resolves after a newer one cannot overwrite state', async () => {
    const calls: string[] = [];
    const fetcher = vi.fn(async (key: string, signal: AbortSignal) => {
      calls.push(key);
      // First call resolves slowly; second call resolves fast.
      const delay = key === 'a' ? 30 : 5;
      await new Promise((resolve) => setTimeout(resolve, delay));
      if (signal.aborted) throw new DOMException('aborted', 'AbortError');
      return { key };
    });

    const { result, rerender } = renderHook(
      ({ key }) => useQuery(key, (signal) => fetcher(key, signal)),
      { initialProps: { key: 'a' } }
    );

    rerender({ key: 'b' });
    await waitFor(() =>
      // The active flag and abort guarantee we land on the latest key,
      // not whichever finishes last.
      expect(result.current.status === 'success' && (result.current.data as any).key).toBe('b')
    );
    // Both fetches were initiated; the slow one was aborted before it
    // could write to state.
    expect(calls).toEqual(['a', 'b']);
  });
});

Interviewer Signal

Tests whether you debug from ownership and lifecycle instead of random dependency-array edits.

Constraints

  • State a hypothesis before changing code.
  • Name what evidence would confirm the bug.
  • Avoid broad rewrites unless the current API cannot express the behavior.

Model Answer Shape

  • Reproduce the failing sequence first.
  • Inspect ownership boundaries: local state, props, effects, subscriptions, and server data.
  • Patch the minimal broken boundary and add a regression test.

Tradeoffs

  • A minimal patch reduces risk, but repeated lifecycle bugs often justify a small reducer or custom hook.
  • Adding dependencies can silence lint warnings while still preserving the wrong ownership model.

Edge Cases

  • Double clicks and repeated submissions.
  • Slow network responses arriving out of order.
  • Component remount with stale persisted state.

Testing And Proof

  • Failing interaction sequence.
  • Out-of-order async response.
  • Unmount cleanup.

Follow-Ups

  • What would the code review comment say?
  • What metric or log would show this in production?