Under the Hood of React Query: A Deep Dive into Its Internal Mechanics
Introduction
At the time of writing, @tanstack/react-query boasts over 6 million weekly downloads on the npm registry. From Fortune 500 companies to indie side projects, this powerful library has become the go-to solution for managing server state in React applications.
As a regular user, I’ve always been fascinated by how React Query abstracts away so many complex behaviors — especially features like automatic garbage collection, dedicated devtools, refetching on window focus, and of course, caching and notifying components of state changes.
In this article, I’ll take you behind the scenes to explore the internals of TanStack Query, showing you how these features work under the hood. Along the way, I’ll share code snippets, conceptual breakdowns, and a mini implementation of key features.
Want to dive straight into the repo and play around?
Implementation
The Pattern
The meat of this is an Observer (alias Publisher — Subscriber) pattern that allows multiple components to subscribe to state changes in the Query.
The best way to think about a query is a promise + some state.
let query = {
..state,
promise: null,
fetch: () => {},
subscribe: (subscriber) => {
// Subscribe to the query
query.subscribers.push(subscriber);
return () => {
// return the unsubscribe callback
query.subscribers = query.subscribers.filter((s) => s !== subscriber);
};
},
setState: (updater) => {
query.state = updater(query.state);
// notify subscribers of state changes (rerender components)
query.subscribers.forEach((subscriber) => subscriber.notify());
},
}
the notify function on a subscribe can be the re-render function for a subscribed component — thereby getting re-rendered each time the state changes.
This will become a bit more clear as we dig into the familiar useQuery
hook
export const useQuery = ({ queryKey, queryFn, staleTime = 0 }) => {
// Get the queryClient object for your application
const client = React.useContext(QueryClientContext);
// Function to force a rerender
const [, rerender] = React.useReducer((i) => i + 1, 0);
// A ref here to ensure a single observer for every instance of useQuery
const observer = React.useRef();
if (!observer.current) {
observer.current = createQueryObserver(client, { queryKey, queryFn });
}
React.useEffect(() => {
// Subscribe on mount
const unsubscribe = observer.current.subscribe(rerender);
// unsubscribe the component on unmount
return () => {
unsubscribe();
};
}, []);
return observer.current.getResult();
};
Wait, what’s this observer you ask? It’s just an object used to link a query to a component.
One thing to note here is that a query is kicked off only after stale time is passed.
const observer = {
notify: () => {},
getResult: () => query.state,
subscribe: (callback) => {
observer.notify = callback;
// subscribe a component's rerender fn to a query
const unsubscribe = query.subscribe(observer);
// kick off the fetch only after stale time has passed
if (
!query.state.lastFetched ||
Date.now() - query.state.lastFetched > staleTime
) {
query.fetch();
}
return unsubscribe;
},
};
Now there’s only a single ingredient left, the QueryClient
—Which is what is stored in the context and passed down through QueryClientProvider.
This object is meant to store queries across your application preventing dupe query objects from being created and taking care of things like invalidation.
export class QueryClient {
constructor() {
// member to store queries
this.queries = [];
}
getQuery = (options) => {
const queryHash = JSON.stringify(options.queryKey);
let query = this.queries.find((q) => q.queryHash === queryHash);
// only create query if not found.
if (!query) {
query = createQuery(this, options);
this.queries.push(query);
}
return query;
};
invalidateQuery = () => {};
}
Here’s a quick diagram for reference:
The fetch
The next step in understanding the internals is by looking at the actual fetch function of a query. With the potential of many components calling query.fetch() we need some deduping to ensure the actual fetch isn’t called more than once concurrently. This is done by having a query.promise that holds the actual fetch method — An async function that propagates state updates to the query (which triggers refetching on the subscribed components)
let query = {
queryHash: JSON.stringify(queryKey),
promise: null,
state: {
data: undefined,
isLoading: true,
isError: false,
isFetching: true,
isSuccess: false,
error: undefined,
lastFetched: null, // To be used for caching based on staleTime
},
setState: () => {
// update state
// notify subscribers
},
fetch: async () => {
// only create the async promise function once
if (!query.promise) {
query.promise = (async () => {
// update state to be fetching
query.setState((prev) => ({
...prev,
error: undefined,
isFetching: true,
}));
try {
const data = await queryFn();
// update state to be success
query.setState((prev) => ({
...prev,
isSuccess: true,
lastFetched: Date.now() // update this timestamp state
data,
}));
} catch (error) {
// update state to be error
query.setState((prev) => ({
...prev,
isError: true,
error,
}));
} finally {
query.promise = null;
// update state to be not fetching
query.setState((prev) => ({
...prev,
isLoading: false,
isFetching: false,
}));
}
})();
}
// if promise is created, return that async function
return query.promise;
},
};
…And we’re done with our very lightweight implementation of React Query.
If you want to play around with just the bare essentials of react query here’s a branch for the repo that has it.
Cool Internal Features (Beyond the Basics)
While not essential to the core data-fetching pipeline, TanStack Query includes some powerful internal features that make it highly performant and developer-friendly.
Garbage collection:
TanStack Query automatically removes unused queries from memory after a configurable period — this is known as garbage collection. It relies on the cacheTime
option passed into useQuery()
Here’s how it works under the hood:
• When the last component unsubscribes from a query, the query isn’t immediately removed.
• Instead, a timer is started for the duration of cacheTime.
• If a new subscriber appears during this window, the timer is canceled.
• If the timer completes, the query is evicted from the cache.
This logic is primarily handled inside the createQuery()
function and its associated Query class.
queryKey,
queryHash: JSON.stringify(queryKey),
subscribe: (subscriber) => {
query.subscribers.push(subscriber);
// everytime a new subscriber is added, clear the GC timeout
if (query.gcTimeout) {
clearTimeout(query.gcTimeout);
query.gcTimeout = null; // reset the timeout
}
return () => {
query.subscribers = query.subscribers.filter((s) => s !== subscriber);
if (query.subscribers.length === 0) {
// schedule garbage collection if no subscribers left
query.scheduleGC();
}
};
},
scheduleGC: () => {
query.gcTimeout = setTimeout(() => {
client.queries = client.queries.filter((q) => q !== query); // remove from client queries
}, cacheTime); // schedule garbage collection after cacheTime
},
setState: () => {},
fetch: async () => {}
Refetching on Window Focus
One of TanStack Query’s most user-friendly features is its ability to automatically refetch data when the browser window regains focus. This keeps your UI fresh by updating potentially stale data whenever the user returns to the app — no manual polling required.
Thanks to the centralized nature of QueryClient, a light weight implementation can be achieved by accessing all of it’s queries and subscribers.
export const QueryClientProvider = ({ children, client }) => {
React.useEffect(() => {
// For all queries in the client, call notify for their subscribers
const onFocus = () => {
client.queries.forEach((query) => {
query.subscribers.forEach((subscriber) => {
subscriber.notify();
});
});
};
window.addEventListener("focus", onFocus, false);
window.addEventListener("visibilitychange", onFocus, false);
return () => {
window.removeEventListener("focus", onFocus, false);
window.removeEventListener("visibilitychange", onFocus, false);
};
}, [client]);
return (
<QueryClientContext.Provider value={client}>
{children}
</QueryClientContext.Provider>
);
};
And there we go — all the internal magic that makes TanStack Query tick, debugged and demystified. Understanding these internals not only helps you use the library more effectively, but also gives you confidence when things don’t go as expected.
Try this yourself: Tweak the staleTime and cacheTime settings in your queries and observe how often data gets fetched behind the scenes. It’s one of the best ways to see TanStack Query’s caching and refetch logic in action— hands-on and in real time.