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),
};
}
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.
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?