Optimizing API Data Usage in React Query with Selectors

Photo by Andrew Neel on Unsplash

Optimizing API Data Usage in React Query with Selectors

Intro

What if there is a way to avoid unnecessary re-renders when re-fetching data if the part(s) of the data that your component is consuming does not change between fetches? The answer is yes, and in this article, we'll explore how selectors can provide a solution.

In the previous post (Mastering API Data transformation) we discussed how you can use the select option in React Query as a native solution to transform data from the API. However, it goes beyond just data transformation. The select feature enables you to selectively subscribe to specific data segments of interest, opening the door for optimization when implementing selectors.

Selector approach

Let’s start by defining some selectors for our custom hook:

const useUser = (select) =>
  useQuery({
    queryKey: ["user"],
    queryFn: () => getUser(),
    refetchOnWindowFocus: true,
    select,
  });

const useUserInfo = () => useUser((user) => `${user.name} - ${user.username}`);

const useUserEmail = () => useUser((user) => user.email);

const useUserPosts = () => useUser((user) => user.posts);

The key distinction on this custom hook is that we are creating our useUser hook to take a select function that allows us to selectively subscribe to pieces of the data that we need. Next, we define several selectors that leverage the select function by providing custom callbacks that fulfill our needs.

Afterward, you can utilize these selectors as follows:

const Header = () => {
  const query = useUserInfo();

  return <h3>User: {query.data}</h3>;
};

const Logout = () => {
  const query = useUserEmail();

  return <button>{query.data}</button>;
};

const Posts = () => {
  const query = useUserPosts();

  return (
    <>
      <strong>Posts:</strong>
      <ul>
        {query.data?.map((post) => (
          <li key={post.title}>{post.title}</li>
        ))}
      </ul>
    </>
  );
};

const Dashboard = () => {
  return (
    <>
      <Header />
      <Posts />
      <Logout />
    </>
  );
};

The beauty of this approach is that even though all of the individual components (Header, Logout and Posts ) depend on the user data, they will only re-render* if the data they are subscribing to changes.

In this case is not very likely that the user, username or email change, but a user can create a post at any time. If when refetching a user the posts are updated, instead of re-rendering all of the components in the Dashboard, only the Posts component will re-render* since it’s the only component subscribed to the posts, which is the only data that changed.

In this scenario, changes to the user, username, or email data are unlikely, but a user can create a post at any time. If, during a user data refresh, the posts get updated, and only the Posts component will re-render because it's the sole component subscribed to the posts data, which is the only data that has changed.

Selectors re-render

I included an asterisk (*) in the previous description because this statement is partially accurate. If you're using React Query versions v4 or v5, you should not observe any rendering when utilizing a selector for data that remains unchanged during a refetch.

However, there's an exception. If you access a field in the query data that actually changes, such as the isFetching property, the component will update. This happens because, on every refetch, this property transitions from true to false, inevitably triggering a re-render.

Let's take a closer look at this:

// ✅ The component won't re-render on every re-fetch
const Header = () => {
  const query = useUserInfo();

  return <h3>{query.data}</h3>;
};

// ❌ The component will re-render since "isFetching" is used
const Header = () => {
  const query = useUserInfo();
  console.log("the user is being fetched", query.isFetching);

  return <h3>{query.data}</h3>;
};

React Query's default behavior is very useful and it helps prevent bugs. Let's consider the isError field as an example when it’s used to display a custom error message like this:

const { isError } = useUserInfo();

return (
  {isError && <p>An error happened, please try again!</p>}
);

Suppose you've successfully completed the initial request; it doesn’t mean that a subsequent re-fetch won't encounter a failure. Consequently, you shouldn’t expect that isError will always remain false.

Therefore, it's important to consistently access the most recent state of the isError field. While this will trigger a re-render, it's entirely anticipated since the value is changing.

Optimization with tracked queries

You might be curious about how this "magic" happens. It's all thanks to the tracked feature.

In simple terms, when you use useQuery, it pays attention to the query fields you're actively using. This means that if you are not using a field that changed between renders, the consumer of the query will not be re-rendered.

However, remember that this is the default behavior only for versions 4 and 5 of React Query.

If you want to opt out of the default smart tracking optimization, you can do so by using the all value in the notifyOnChangeProps field:

const useUser = (select) =>
  useQuery({
    queryKey: ["user"],
    queryFn: () => getUser(),
    refetchOnWindowFocus: true,
    select
    // remove the "tracking" optimization
    notifyOnChangeProps: "all"
  });

If you're working with React Query version 3 you can set notifyOnChangeProps to 'tracked', or set it as a default for all queries:

// react-query v3 configuaration
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      notifyOnChangeProps: 'tracked',
    },
  }
});

Structural Sharing

Another default feature of React Query is structural sharing. It guarantees referential data identity across all levels.

This data reference is highly beneficial for React Query because it enables effective comparison between the old and new states since it tries to keep as much of the previous state as possible.

We can illustrate this with the addition of a new selector called usePost:

const useUserPosts = () => useUser((user) => user.posts);

// subscribe to individual post
const useUserPost = (id) =>
  useUser((user) => user.posts.find((post) => post.id === id));

Then the optimization happens If the user updates a post. For instance, if the post with id of 4 was updated, only the components subscribed to that specific post will be re-rendered:

const Post = ({ id }) => {
  const {
    data: { title, content } = {}
  } = useUserPost(id);
  console.log(`Post with title: ${title}, re-rendered: ${content}.`);

  return (
    <p>
      Title: {title}, Content: {content}{" "}
    </p>
  );
};

const Posts = () => (
  <>
    <Post id={2} />
    <Post id={4} /> {/* Will re-render due to the update */}
  </>
);

When data is re-fetched, the post with an id of 4 will be different, while the post with an id of 2 will remain the same. React Query will perform a comparison between the previous data (the array of posts) and the newly fetched data. Since the post with an id of 2 remains unchanged and retains the same reference as in the previous state, it will be "copied" because there are no changes. As a result, the consumers of this partial subscription won't need to re-render.

Like most optimizations, it also comes with some drawbacks. In some cases dealing with large and complex datasets, the comparison step might not be ideal. Also, this feature will only work on JSON-serializable data. If these are deal breakers to you, you can opt-out on your query by adding the structuralSharing option as false like this:

As with many optimizations, this approach does have some downsides. When handling large and complex datasets, the comparison step may not be ideal in certain cases. Additionally, please note that this feature will only work with JSON-serializable data.

If these limitations are a deal-breaker to you, you can opt-out on your query by adding the structuralSharing option as false like this:

useQuery({ 
  queryKey: ['my-key'], 
  queryFn: () => fetchData(), 
  structuralSharing: false 
});

Testing

You can find a live demo of the previous examples in this CodeSandbox. I've included the refetchOnWindowFocus: true option to useQuery to test the functionality of the selectors. This setting triggers a re-fetch each time you switch to a different browser tab and return to the CodeSandbox demo.

To verify this behavior, open the console, clear the current logs, switch to a different tab in your browser, and then return to the demo tab. You should see a new log in the console originating from the updated post, which is the expected behavior. As you can see, other components dependent on the same query won’t re-render, even though a new GET request is performed (you can also verify this in the network tab).

I hope you found this exploration of implementing a selectors with React Query to optimize re-renders in your components enjoyable. Until the next one! 😊