A query client is the coordination layer under a data-fetching library: it caches server data by key, knows when that data is stale, collapses concurrent requests for the same key into one, and pushes updates to whoever is watching. You are going to build a minimal one — the core that a library like TanStack Query (React Query) wraps hooks around.
miniReactQueryCore() returns a client with four methods: fetchQuery to read data (from cache when fresh, from the network otherwise), getQueryData to peek at the cache, subscribe to be notified when a key's data changes, and invalidate to force a key to refetch. The interesting part is how these cooperate: a burst of fetchQuery calls must share one request, a fresh cache must skip the fetcher entirely, and an invalidation must refresh every live subscriber.
The whole thing is keyed by a string you choose, and each key runs independently — its own cached value, its own in-flight request, its own subscribers. A failed fetch is never written to the cache, so the next call for that key gets a clean retry rather than a stored error. Getting the ordering of the checks right — dedupe, then freshness, then fetch — is what separates a working client from one that either over-fetches or serves stale data.
function miniReactQueryCore(): QueryClient;
interface QueryClient {
// Resolve data for `key`. Serve the cache when it is still fresh
// (Date.now() - updatedAt < staleTime); otherwise call fetcher() once,
// sharing one in-flight promise across concurrent callers, then cache
// { data, updatedAt } and notify subscribers.
fetchQuery<T>(
key: string,
fetcher: () => Promise<T>,
options?: { staleTime?: number }, // default 0
): Promise<T>;
// The cached data for `key`, or undefined if nothing is cached yet.
getQueryData(key: string): unknown;
// Register cb, called with the new data on every change for `key`.
// Returns an unsubscribe function.
subscribe(key: string, cb: (data: unknown) => void): () => void;
// Mark `key` stale so the next fetchQuery refetches regardless of
// staleTime; if `key` has subscribers, refetch and notify them now.
invalidate(key: string): void;
}
const client = miniReactQueryCore();
const getUser = () => fetch('/api/user').then((r) => r.json());
// Two components mount at once and both ask for the user.
const a = client.fetchQuery('user', getUser);
const b = client.fetchQuery('user', getUser);
a === b; // true — getUser ran once; both callers await the same request
await a;
client.getQueryData('user'); // { name: 'Ada' } — now cached
const client = miniReactQueryCore();
// Fresh for 5s: a second call inside the window skips the fetcher.
await client.fetchQuery('user', getUser, { staleTime: 5000 });
await client.fetchQuery('user', getUser, { staleTime: 5000 }); // cache hit, no request
const off = client.subscribe('user', (data) => render(data));
client.invalidate('user'); // refetches now, then calls render(freshData)
off(); // stop listening
staleTime: 0 (the default) means always refetch.Date.now() for staleness — compare Date.now() - entry.updatedAt against staleTime. You do not need high-resolution timers, and you do not need to cancel or debounce anything.Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.