React Query Cleanup Functions
We use react-query extensively at Logikcull and it has worked well for us. I won’t talk about it in this blog post but I encourage anyone interested to look at their excellent docs and sample projects. It’s an absolute game-changer in my opinion for simplifying server state in apps.
For most use-cases of web apps, react-query works fine out of the box. There is a default cacheTime
field of 5 minutes, which is the time unused queries have before they are removed from the cache.1 This would normally be fine for a simple app fetching a list of users, for example. You don’t need to think about the cacheTime
when the data stored in the cache is small.
Usage at Logikcull
However, at Logikcull we have a document viewer where someone can be browsing through multiple PDFs that are hundreds of MBs, or even more than a GB in size. We do this using pdf.js, a pdf-reading javascript library that mozilla has worked on for many years. When parsing a pdf, we spin up a web worker and stream in chunks of the pdf, all of which can be memory-intensive for large pdfs. Without proper cleanup, we were noticing the browser memory growing unbounded, and eventually crashing all together.
So, how can we actually cleanup the memory / data associated with react-query queries?
Meta Field
useQuery
includes a meta
field, which is an arbitrary object you can put whatever you want into. We define a onRemoveFromCache
function which we will invoke when a query is removed from the cache. We can call a global useObserveQueryCache
hook which looks like:
export function useObserveQueryCache() {
const queryClient = useQueryClient();
useEffect(() => {
const queryCache = queryClient.getQueryCache();
const unsubscribe = queryCache.subscribe((event) => {
const { meta, state, queryKey } = event.query;
if (event.type === "removed" && meta.onRemoveFromCache) {
meta.onRemoveFromCache(state, queryKey);
}
});
return unsubscribe;
}, [queryClient]);
}
And then the usage of the query can look like:
useQuery({
// some memory-expensive stuff..
queryFn: () => {},
meta: {
onRemoveFromCache: (queryState, queryKey) => {
// terminate web workers, cleanup whatever you need to do
},
},
});
Boom! That’s all that we need - we can define this for every query, instead of manually observing to a query removal in each place. We now have several useQuery
instances related to pdf parsing and other web workers that we cleanup with this method. Unfortunately you currently can’t provide typescript types for the meta
field of useQuery
, but perhaps this will change in the future.
Cache Time
The last part needed here was to manually adjust the cacheTime
value based on the size of the documents. For anything above 50MB in size, we set the cacheTime
to 0 so it is immediately garbage-collected (and triggers the onRemoveFromCache
) when the query is unused. This means that when a user switches from a large document to a next one, the previous document will be totally cleaned up from the cache. The downside is a user won’t be able to quickly toggle between 2 large documents and have them stay cached, but we found this was the best solution available. Without this, a user could browse through several massive pdfs and easily crash the browser.
Footnotes
-
Note - this field is being renamed to
gcTime
in v5 of the library, due to common developer confusion about the behavior. ↩