Sneaky React Memory Leaks II: Closures Vs. React Query

Sneaky React Memory Leaks II: Closures Vs. React Query

May 29, 2024

This is a follow-up to "Sneaky React Memory Leaks: How useCallback and closures can bite you". See that post for a general introduction to closures and memory leaks in React.*

In this post, I will show you how React Query can lead to memory leaks due to closures as well. I will explain why this happens and how to fix it.

If you have read the previous article, this will all sound familiar but hopefully helps React Query users to find this issue faster.

The Problem

A common React Query pattern is to use the useQuery hook to fetch a data entry from an API by its id. The hook takes a key and a function that fetches the data. The key is used to cache the data and to invalidate it when needed. Whenever the key changes, React Query will create a new query.

Let's look at a simple example that fetches a post from the JSON Placeholder API by its id.

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";

interface Post {
  id: string;
  title: string;
  body: string;
  userId: string;
}

class BigObject {
  public readonly data = new Uint8Array(1024 * 1024 * 10); // 10MB of data
}

async function fetchPost(id: string): Promise<Post> {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${id}`
  );
  return await response.json();
}

export function App() {
  const [id, setId] = useState(1);

  const { data, error, isPending } = useQuery({
    queryKey: ["posts", id],
    queryFn: async () => {
      return fetchPost(String(id));
    },
  });

  // This is only here to demonstrate the memory leak
  const bigData = new BigObject();
  function handleClick() {
    console.log(bigData.data.length);
  }

  if (isPending) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return (
    <div>
      <p>Id: {id}</p>
      <button onClick={() => setId((p) => p + 1)}>Next</button>
      <div>
        <h1>Title: {data.title}</h1>
        <p>{data.body}</p>
        <button onClick={handleClick}>Log Big Data</button>
      </div>
    </div>
  );
}

If you run this code and click the "Next" button a few times, you will notice that the memory usage of your application keeps increasing with each click.

"Memory snapshot of the application" Growing memory usage in the Chrome DevTools

This is because the bigData object is never garbage collected. (Why it's kept exactly 8 times is a story for a different post.)

The problem, as in my previous post, are the closures. The handleClick function, the () => setId((p) => p + 1) arrow function, and the queryFn access the App function's scope. Hence, a context object is created in the JS engine to keep track of the variables after the App function has finished. This context object (or [[Scope]] in the debugger) is shared by both functions. So as long as one of them is still in memory, bigData will not be garbage collected.

Shared scope queryFn holding a reference to the App scope

Now here is the issue with React Query: To refetch the data later, the queryFn is kept in the query client's internal cache, possibly for a very long time. But since the queryFn holds a reference to the context object, it will also pull everything into the cache that was in scope when the queryFn was created. In our case this includes bigObject with its 10MB array. - Oops.

(At least if you don't cache anything, the memory usage will stay constant. But who doesn't want to cache data?)

FYI: The bigData object is only here to demonstrate the memory leak. In a real-world application, you might have a bunch of state, props, callbacks and other objects that are retained in memory.

The Solution - Custom Hooks

Custom hooks to the rescue! You can create a custom hook that takes the id as an argument and returns the useQuery result. Creating a new hook function will create another function scope. This function scope will only contain the id and no longer the bigData object.

This is the best approach and the only sure way to tell your JS engine what's ok to capture and what's not.

export function usePost(id: number) {
  const { renewToken } = useAuthToken();

  return useQuery({
    queryKey: ["posts", id],
    queryFn: async () => {
      const token = await renewToken(); // This is fine
      return fetchPost(String(id));
    },
  });
}

Now, you can use the usePost hook in your App component.

// ...

// The custom hook
function usePostQuery(id: number) {
  return useQuery({
    queryKey: ["posts", id],
    queryFn: async () => {
      return fetchPost(String(id));
    },
  });
}

export function App() {
  const [id, setId] = useState(1);

  const bigData = new BigObject();
  function handleClick() {
    console.log(bigData.data.length);
  }

  // Use the custom hook here
  const { data, error, isPending } = usePostQuery(id);

  if (isPending) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return (
    <div>
      <p>Id: {id}</p>
      <button onClick={() => setId((p) => p + 1)}>Next</button>
      <div>
        <h1>Title: {data.title}</h1>
        <p>{data.body}</p>
        <button onClick={handleClick}>Log Big Data</button>
      </div>
    </div>
  );
}

Reduced memory usage Memory usage stays constant

Check out TKDodo's blog for guidance on how to create custom hooks.

For The Curious - Things That Don't Work

My initial thought was to simply not access the outer scope in the queryFn function by using the id from the queryKey. Check out TKDodo's blog post on the QueryFunctionContext for how to do that elegantly.

It would look like this:

export function App() {
  const [id, setId] = useState(1);

  // ...

  const { data, error, isPending } = useQuery({
    queryKey: ["posts", id],
    queryFn: async ({ queryKey }) => {
      const id = queryKey[1];
      return fetchPost(String(id));
    },
  });

  // ...
}

Sadly, this doesn't work. As soon as you define the queryFn within the App function, it will add the App closure to its scope chain and keep the bigData object in memory. Even if you don't even access any variable or function from any outside scope(s). The presence of other functions that close over variables from App is enough.

Even this will still leak memory:

export function App() {
  const [id, setId] = useState(1);

  // ...

    const { data, error, isPending } = useQuery({
    queryKey: ["posts", id],
    queryFn: function () {
      return {
        title: "Foo",
        body: "Bar",
      };
    },
  });

  // ...
}

Seems like the usage of other closures in the App function is enough for the queryFn to capture the whole scope. This means that there is no way around using custom hooks.

Still leaking memory Still leaking memory, queryFn still referencing the App closure

Conclusion

I love React Query and I think it's a great library. However, you better make sure that you don't accidentally introduce memory leaks by using closures the wrong way. Hence, the only thing that really helps:

🚨 Always use custom hooks to encapsulate your logic and avoid capturing unnecessary variables! 🚨

Feedback?

I hope this article helps you to understand how React Query can lead to memory leaks and how to fix them. If you feel that all this closure stuff is very hard to reason about, you are not alone. It's a complex topic and it's too easy to make mistakes.

If you have any questions or feedback, feel free to reach out or leave a comment below.