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.
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.
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>
);
}
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, 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.