All projects
Minimal Query
LiveReact Query's core rebuilt from scratch — stable key hashing, request deduplication, stale time, garbage collection — to find out where the behaviour actually comes from.
- Period
- 2025 — Present
- TypeScript
- React
I had used React Query for years without being able to explain what happens
between calling useQuery twice with the same key and getting one network
request. So I rebuilt the core — about four hundred lines, four objects.
- QueryClient — entry point. Defaults, plus a reference to the cache.
- QueryCache — a
Mapfrom hashed key toQuery. Where deduplication lives. - Query — one key’s state and lifecycle, plus the in-flight promise and the GC timer.
- QueryObserver — the bridge to a component. Subscribes, decides whether a fetch is needed, unsubscribes on unmount.
Deduplication isn’t a feature, it’s a location
async fetch() {
if (this.activePromise) {
return this.activePromise
}
// ...run, then clear activePromise in `finally`
}
- Two components with the same key never coordinate. They subscribe to the same
Query, and thatQueryowns the single in-flight promise — so the second caller is handed the first one. - Nothing is deduplicating anything. There was only ever one request, because there’s only ever one place for it to live.
- Clearing in
finallyrather than after theawaitis what makes the next fetch possible after a failure. - It only holds if identical-looking keys hash the same, so the hash is
JSON.stringifywith a replacer that sorts plain-object keys. Without it,{ page: 1, sort: 'asc' }and{ sort: 'asc', page: 1 }are a silent cache-miss generator.
Stale time and GC time sound alike, do opposite jobs
// QueryObserver — should we go get fresh data?
if (!lastUpdatedAt || Date.now() - lastUpdatedAt.getTime() > staleTime) {
this.query.fetch()
}
// Query — should we drop this entry entirely?
unsubscribe(observer) {
this.subscribers.delete(observer)
if (this.subscribers.size === 0) this.scheduleGarbageCollection()
}
- Stale time — how long data is trusted without refetching. Checked by the observer at subscribe time.
- GC time — how long an unused query is kept. A timer the query arms when its last subscriber leaves, and clears the moment one arrives.
- They never consult each other. A query can be stale and still cached, or fresh and about to be collected. Confusing the two is most of why cache behaviour feels unpredictable before you’ve seen an implementation.
- It’s also why mount → unmount → remount inside the window keeps its data: the timer is cancelled before it ever fires.
The parts I’d write differently
- The React binding is a
useReducercounter bumped on every notification, purely to force a re-render. Honest about being a hack — the real library usesuseSyncExternalStore, which also handles tearing under concurrent rendering. Leaving the cruder version in made the difference legible. refetchOnWindowFocus: truesits in the defaults and is never read by anything. A leftover from copying the shape of the API before understanding which parts carry weight.
Scope
Educational, not a replacement. No suspense, no infinite queries, no mutations, no retries, no devtools. The goal was to read the real library’s source and recognise everything in it — which worked.