← Back to question bank
React HookMidMedium#4009 · 40m

Implement custom useQuery hook in a product component

Use custom useQuery hook to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.

Answer Strategy

A custom useQuery is the question that exposes whether you can keep async lifecycle separate from rendering. The senior answer names three failure modes the hook prevents: stale resolutions overwriting newer state, leaks from setState-after-unmount, and unbounded re-fetching from a fetcher whose identity changes every render. The fix is two correctness primitives — an active flag plus AbortController — and one performance discipline: stable deps.

Cache, key, and version are three orthogonal concerns. The cache is a Map keyed by the request key (no React state needed). The key drives re-subscription. The version drives manual refetch without changing the key. Treat refetch() as a controlled bump rather than a fire-and-forget side effect, and you can reason about what triggers a fetch by looking at the dep array alone.

Adjacent traps: returning a refetch closure that captures stale state (use the version-bump pattern), forgetting to short-circuit the cached branch (every refetch should hit the network), and storing the cache in component state (it must outlive remounts). The reference is intentionally minimal — production should add per-key in-flight dedup, stale-while-revalidate, and a focus-refresh signal, but the bones are these.

Reference Implementation: Cancellable Cached useQuery

A small useQuery with an in-memory cache, AbortController-based cancellation, and an explicit refetch via a version bump.

// A minimal useQuery: cache by key, abort the previous in-flight request
// when the key changes, and ignore stale resolutions so the latest user
// intent always wins.

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

const cache = new Map<string, unknown>();

export function useQuery<T>(
  key: string,
  fetcher: (signal: AbortSignal) => Promise<T>
): QueryState<T> & { refetch: () => void } {
  const [state, setState] = React.useState<QueryState<T>>(() => {
    const cached = cache.get(key) as T | undefined;
    return cached !== undefined ? { status: 'success', data: cached } : { status: 'idle' };
  });
  const [version, setVersion] = React.useState(0);

  React.useEffect(() => {
    const controller = new AbortController();
    let active = true;

    setState(
      (cache.has(key)
        ? { status: 'success', data: cache.get(key) as T }
        : { status: 'loading' })
    );

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

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

  return {
    ...state,
    refetch: () => setVersion((v) => v + 1),
  };
}

Runnable Playground

Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.


// A minimal useQuery: cache by key, abort the previous in-flight request
// when the key changes, and ignore stale resolutions so the latest user
// intent always wins.

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

const cache = new Map<string, unknown>();

export function useQuery<T>(
  key: string,
  fetcher: (signal: AbortSignal) => Promise<T>
): QueryState<T> & { refetch: () => void } {
  const [state, setState] = React.useState<QueryState<T>>(() => {
    const cached = cache.get(key) as T | undefined;
    return cached !== undefined ? { status: 'success', data: cached } : { status: 'idle' };
  });
  const [version, setVersion] = React.useState(0);

  React.useEffect(() => {
    const controller = new AbortController();
    let active = true;

    setState(
      (cache.has(key)
        ? { status: 'success', data: cache.get(key) as T }
        : { status: 'loading' })
    );

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

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

  return {
    ...state,
    refetch: () => setVersion((v) => v + 1),
  };
}
TypeScript · runnable

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.

Cancellation
Trigger a key change before the previous fetch resolves. Assert the previous request was aborted (via the AbortSignal handler in the fetcher) and that only the new value lands in state.
Lifecycle
Unmount mid-flight; resolve the in-flight promise; assert no setState fires (no React act warning, no observable state change). The active flag protects this path even when AbortController is unavailable in a polyfilled environment.
Dep stability
Pass a fetcher that is recreated every render; the test should fail. Memoize it via useCallback at the call site and the test should pass — surface the dep-stability requirement in the API contract, not as a hidden assumption.
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';

describe('useQuery', () => {
  it('returns the latest fetched value and aborts the previous request', async () => {
    const fetcher = vi.fn(async (signal: AbortSignal) => {
      await new Promise((r) => setTimeout(r, 5));
      if (signal.aborted) throw new DOMException('aborted', 'AbortError');
      return { ok: true };
    });

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

    rerender({ key: 'b' });
    await waitFor(() => expect(result.current.status).toBe('success'));

    // The "a" call was aborted; only the most recent key resolves into state.
    expect(fetcher).toHaveBeenCalledTimes(2);
  });

  it('does not write stale results when the component unmounts mid-flight', async () => {
    let resolveFn: (v: { ok: true }) => void = () => {};
    const fetcher = () => new Promise<{ ok: true }>((r) => (resolveFn = r));
    const { result, unmount } = renderHook(() => useQuery('x', fetcher));
    unmount();
    act(() => resolveFn({ ok: true }));
    // No setState after unmount; the test passes if React produces no warning.
    expect(result.current.status).toBe('idle');
  });
});

Interviewer Signal

Shows whether you can explain React behavior while building maintainable product UI.

Constraints

  • Name what is render-derived and what is stored state.
  • Keep side effects owned by events or effects deliberately.
  • Provide a testable boundary for business logic.

Model Answer Shape

  • Start from the user workflow and state ownership.
  • Move pure decisions out of JSX when the branch logic grows.
  • Use React primitives to express ownership, not to hide unclear state.

Tradeoffs

  • Colocating state improves clarity until sibling coordination becomes the real problem.
  • Memoization helps only after render cost or identity churn is measured.

Edge Cases

  • Strict Mode re-running development effects.
  • Stale closures after async work resolves.
  • Unmounts and route changes during in-flight operations.

Testing And Proof

  • Reducer or pure function test for core state transitions.
  • Interaction test for the user workflow.
  • Regression case for stale or repeated async behavior.

Follow-Ups

  • How would this change with server rendering?
  • Where would you place this state in a larger app?