Peter Krieg's blog

React Query Cleanup Functions

Last updated on

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.

The Chrome Devtools Performance Monitor is helpful for monitoring memory usage in real-time. This picture mostly just shows some fancy charts, but you can see the JS heap size has grown up to 520MB.

shows memory going downHere, you can see a sharp dropoff in memory usage from the cleanup

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

  1. Note - this field is being renamed to gcTime in v5 of the library, due to common developer confusion about the behavior.