Subscribing to React Query cache updates

· Christopher Hoelter's Blog

Handling react state side effects while using React Query.

React query is a powerful library that eliminates much of the boilerplate needed to handle state updates and caching when loading data. The basic implementation has a component use the libraries useQuery hook that takes a query key and fetch method. There is an inherent coupling here where a component must know a specific query key in order to retrieve data directly from the reacy query cache. For many use cases this is enough, and components can fetch the data they need and react query can manage that datas caching and refreshing.

Sometimes that may not be enough. In a situation where data needs to be stitched together from multiple query caches for aggregate access, it becomes less straight forward to use React Query. Separating how the data is loaded from the consumption of the data is beneficial in this case. By building a layer that can take the data from different queries and combine it into a single data store, consumers of that data can have a single point of access and no longer need to care about the specific queries that should be used in different contexts. This type of solution adds complexity and should only be reached for if truly needed.

To update centralized state after data is loaded using React Query, a basic approach would be to handle that in a useEffect. Below is a contrived example of "posts" loading for a single user, new user posts loading, and a component that would display all those posts together. Consider that the single user could also be part of the "new users" and their posts could be loaded in both locations.

function useUserPosts({ userId }) {
  const updatePostsDataStore = useUpdatePostsDataStore(); // This hook could be wrapping any type of global data store updater (react context, dispatching to redux, a jotai atom, etc)

  const { data } = useQuery({
    queryKey: [{ scope: 'userPosts', userId: userId }],
    queryFn: ({ queryKey }) =>
      fetch(`https://example.com/user/${queryKey[0].userId}/posts`).then(
        (res) => res.json(),
      ),
  })

  useEffect(() => {
    if (data) {
      updatePostsDataStore(data);
    }
  }, [data]);

  return data;
}

function useNewUserPosts() {
  const updatePostsDataStore = useUpdatePostsDataStore();

  const { data } = useQuery({
    queryKey: [{ scope: 'postsFromNewUsers' }],
    queryFn: ({ queryKey }) =>
      fetch(`https://example.com/postsFromNewUsers`).then(
        (res) => res.json(),
      ),
  })

  useEffect(() => {
    if (data) {
      updatePostsStore(data);
    }
  }, [data]);

  return data;
}

function UserPostsComponent() {
  const userPosts = useUserPosts(1);
  return <UserPostsDisplay posts={userPosts} />;
}

function NewUserPostsComponent() {
  const userPosts = useNewUserPosts();
  return <UserPostsDisplay posts={userPosts} />;
}

function AllPostsComponent() {
  const allPosts = usePostsFromStore();
  return <AllPostsDisplay posts={allPosts} />;
}

function ExampleApp() {
  <div>
    <UserPostsComponent />
    <NewUserPostsComponent />
    <AllPostsComponent />
  </div>
}

Witht this when react query refetches, the useEffect runs again and the data store will be updated. What happens if a component using this hook unmounts and remounts? What happens if the data for this query is marked to never go stale? What happens if this hook is used in multiple component instances? In all these cases, there is a chance that the data store will be redundantly updated at best. At worst, if the data store has been updated by another endpoint elsewhere, stale data could be potentially overriding more fresh data unless it's specifically handled in the logic updating the data store.

In order to improve upon this, the data store should only be updated a single time when new data is actually fetched by react-query. This can be done by creating a queryCache subscriber that lives high up in the component tree and will be mounted for once for the scope of the app that needs its queries monitored.

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

function useReactQuerySubscription() {
  const queryClient = useQueryClient();
  const updatePostsDataStore = useUpdatePostsDataStore();

  useEffect(() => {
    const queryCache = queryClient.getQueryCache();

    const unsub = queryCache.subscribe((event) => {

      // Only run when new data is actually fetched successfully that updates the query cache
      if (event.type === "updated" && event.action.type === "success") { 

        // just checking for the scope and ignoring individual userIds means this handles _all_ individual user post query updates
        switch (devent.query.queryKey[0].scope) {
          case 'userPosts':
            updatePostsStore(event.action.data);
          break;

          case 'postsFromNewUsers':
            // This is the same logic as the above case, but imagine the shape of data being slightly different and some varied mapping logic needed at this point
            updatePostsStore(event.action.data);
          break;

          default:
          // nothing for unhandled cases
        }
      }
    });

    return () => {
      unsub();
    };
  }, []);
}

With this hook, the side effects of updating a data store can be separated out from the loading of data. Components that handle loading data no longer have to manage updating the normalized data store, and components that consume the data can hook into the data store without needing to know the precise query key to access the cache where the data originated.

To update the previous example, it would now look more like this

function useUserPosts({ userId }) {
  const { data } = useQuery({
    queryKey: [{ scope: 'userPosts', userId: userId }],
    queryFn: ({ queryKey }) =>
      fetch(`https://example.com/user/${queryKey[0].userId}/posts`).then(
        (res) => res.json(),
      ),
  })

  return data;
}

function useNewUserPosts() {
  const { data } = useQuery({
    queryKey: [{ scope: 'postsFromNewUsers' }],
    queryFn: ({ queryKey }) =>
      fetch(`https://example.com/postsFromNewUsers`).then(
        (res) => res.json(),
      ),
  })

  return data;
}

function UserPostsComponent() {
  const userPosts = useUserPosts(1);
  return <UserPostsDisplay posts={userPosts} />;
}

function NewUserPostsComponent() {
  const userPosts = useNewUserPosts();
  return <UserPostsDisplay posts={userPosts} />;
}

function AllPostsComponent() {
  const allPosts = usePostsFromStore();
  return <AllPostsDisplay posts={allPosts} />;
}

function App() {
  // This is monitoring query updates across all sub components
  useReactQuerySubscription();

  <div>
    <UserPostsComponent />
    <NewUserPostsComponent />
    <AllPostsComponent />
  </div>
}

Please leave a comment or drop a message at https://lists.sr.ht/~hoelter/public-inbox. Email me directly at blog@christopherhoelter.com.