Skip to content
TkDodo's blog
BlueskyGithub

React Query Render Optimizations

โ€” ReactJs, React Query, JavaScript, TypeScript โ€” 4 min read

optimizations

Last Update: 2023-10-21

  • ํ•œ๊ตญ์–ด
  • ๆญฃ้ซ”ไธญๆ–‡
  • Espaรฑol
  • ็ฎ€ไฝ“ไธญๆ–‡
  • ๆ—ฅๆœฌ่ชž
  • Add translation

I've already written quite a bit about render optimizations when describing the select option in #2: React Query Data Transformations. However, "Why does React Query re-render my component two times even though nothing changed in my data" is the question I probably needed to answer the most (apart from maybe: "Where can I find the v2 docs" ๐Ÿ˜…). So let me try to explain it in-depth.

isFetching transition

I haven't been entirely honest in the last example when I said that this component will only re-render if the length of todos change:

count-component
1export const useTodosQuery = (select) =>
2 useQuery({
3 queryKey: ['todos'],
4 queryFn: fetchTodos,
5 select,
6 })
7export const useTodosCount = () =>
8 useTodosQuery((data) => data.length)
9
10function TodosCount() {
11 const todosCount = useTodosCount()
12
13 return <div>{todosCount.data}</div>
14}

Every time you make a background refetch, this component will re-render twice with the following query info:

1{ status: 'success', data: 2, isFetching: true }
2{ status: 'success', data: 2, isFetching: false }

That is because React Query exposes a lot of meta information for each query, and isFetching is one of them. This flag will always be true when a request is in-flight. This is quite useful if you want to display a background loading indicator. But it's also kinda unnecessary if you don't do that.

notifyOnChangeProps

For this use-case, React Query has the notifyOnChangeProps option. It can be set on a per-observer level to tell React Query: Please only inform this observer about changes if one of these props change. By setting this option to ['data'], we will find the optimized version we seek:

optimized-with-notifyOnChangeProps
1export const useTodosQuery = (select, notifyOnChangeProps) =>
2 useQuery({
3 queryKey: ['todos'],
4 queryFn: fetchTodos,
5 select,
6 notifyOnChangeProps,
7 })
8export const useTodosCount = () =>
9 useTodosQuery((data) => data.length, ['data'])

You can see this in action in the optimistic-updates-typescript example in the docs.

Staying in sync

While the above code works well, it can get out of sync quite easily. What if we want to react to the error, too? Or we start to use the isLoading flag? We have to keep the notifyOnChangeProps list in sync with whichever fields we are actually using in our components. If we forget to do that, and we only observe the data property, but get an error that we also display, our component will not re-render and is thus outdated. This is especially troublesome if we hard-code this in our custom hook, because the hook does not know what the component will actually use:

outdated-component
1export const useTodosCount = () =>
2 useTodosQuery((data) => data.length, ['data'])
3
4function TodosCount() {
5 // ๐Ÿšจ we are using error,
6 // but we are not getting notified if error changes!
7 const { error, data } = useTodosCount()
8
9 return (
10 <div>
11 {error ? error : null}
12 {data ? data : null}
13 </div>
14 )
15}

As I have hinted in the disclaimer in the beginning, I think this is way worse than the occasional unneeded re-render. Of course, we can pass the option to the custom hook, but this still feels quite manual and boilerplate-y. Is there a way to do this automatically? Turns out, there is:

Tracked Queries

I'm quite proud of this feature, given that it was my first major contribution to the library. If you set notifyOnChangeProps to 'tracked', React Query will keep track of the fields you are using during render, and will use this to compute the list. This will optimize exactly the same way as specifying the list manually, except that you don't have to think about it. You can also turn this on globally for all your queries:

tracked-queries
1const queryClient = new QueryClient({
2 defaultOptions: {
3 queries: {
4 notifyOnChangeProps: 'tracked',
5 },
6 },
7})
8function App() {
9 return (
10 <QueryClientProvider client={queryClient}>
11 <Example />
12 </QueryClientProvider>
13 )
14}

With this, you never have to think about re-renders again. Of course, tracking the usages has a bit of an overhead as well, so make sure you use this wisely. There are also some limitations to tracked queries, which is why this is an opt-in feature:

  • If you use object rest destructuring, you are effectively observing all fields. Normal destructuring is fine, just don't do this:
problematic-rest-destructuring
1// ๐Ÿšจ will track all fields
2const { isLoading, ...queryInfo } = useQuery(...)
3
4// โœ… this is totally fine
5const { isLoading, data } = useQuery(...)
  • Tracked queries only work "during render". If you only access fields during effects, they will not be tracked. This is quite the edge case though because of dependency arrays:
tracking-effects
1const queryInfo = useQuery(...)
2
3// ๐Ÿšจ will not corectly track data
4React.useEffect(() => {
5 console.log(queryInfo.data)
6})
7
8// โœ… fine because the dependency array is accessed during render
9React.useEffect(() => {
10 console.log(queryInfo.data)
11}, [queryInfo.data])
  • Tracked queries don't reset on each render, so if you track a field once, you'll track it for the lifetime of the observer:
no-reset
1const queryInfo = useQuery(...)
2
3if (someCondition()) {
4 // ๐ŸŸก we will track the data field if someCondition was true in any previous render cycle
5 return <div>{queryInfo.data}</div>
6}

Structural sharing

A different, but no less important render optimization that React Query has turned on out of the box is structural sharing. This feature makes sure that we keep referential identity of our data on every level. As an example, suppose you have the following data structure:

1[
2 { "id": 1, "name": "Learn React", "status": "active" },
3 { "id": 2, "name": "Learn React Query", "status": "todo" }
4]

Now suppose we transition our first todo into the done state, and we make a background refetch. We'll get a completely new json from our backend:

1[
2- { "id": 1, "name": "Learn React", "status": "active" },
3+ { "id": 1, "name": "Learn React", "status": "done" },
4 { "id": 2, "name": "Learn React Query", "status": "todo" }
5]

Now React Query will attempt to compare the old state and the new and keep as much of the previous state as possible. In our example, the todos array will be new, because we updated a todo. The object with id 1 will also be new, but the object for id 2 will be the same reference as the one in the previous state - React Query will just copy it over to the new result because nothing has changed in it.

This comes in very handy when using selectors for partial subscriptions:

optimized-selectors
1// โœ… will only re-render if _something_ within todo with id:2 changes
2// thanks to structural sharing
3const { data } = useTodo(2)

As I've hinted before, for selectors, structural sharing will be done twice: Once on the result returned from the queryFn to determine if anything changed at all, and then once more on the result of the selector function. In some instances, especially when having very large datasets, structural sharing can be a bottleneck. It also only works on json-serializable data. If you don't need this optimization, you can turn it off by setting structuralSharing: false on any query.

Have a look at the replaceEqualDeep tests if you want to learn more about what happens under the hood.


Phew, this was quite a handful. Feel free to reach out to me on bluesky if you have any questions, or just leave a comment below. โฌ‡๏ธ I'm always happy to help!

ยฉ 2024 by TkDodo's blog. All rights reserved.
Theme by LekoArts