Alto / Blog / Engineering

Why We Switched to React Query for Managing Server State

React Query

Redux is great for managing client state: it allows data to be synchronized across the whole app, reduces prop drilling, and makes it easier to understand when state changes occur.

Server state is a different story. Sure, it’s nice to have data available everywhere in our app, which is why we tend to fetch data from the server and dump it into our Redux store. But because our client application doesn't own that server data, it’s hard to know when it has become stale. And what if multiple components trigger calls to fetch the same data? How do you dedupe requests? How do you decide when to refetch?

Below, we’ll dive into how we’re using React Query at Alto to solve these problems and achieve separation of client and server state.

The Old Way: Fetch Server Data and Store It in State

Historically, we would fetch data and set the response in our React component state.

fetchUser(params) {
 this.setState({ isLoading: true });
 
 return getUser(params)
   .then(({ data }) => {
     this.setState({
       isLoading: false,
       user: data.user,
     });
   })
   .catch(() => {
     toaster.toast({ title: 'Something went wrong...' });
     this.setState({ isLoading: false });
   });
}

To make that data accessible from other components, we’d set that data in the global Redux store. This works fine, except that you have to manage your own loading state, the logic for when and when not to make the request, updating the store when mutating data on the server, and knowing when data is stale.

The React Query Way: Fetch and Cache Server Data

With React Query, fetching data and using that data is more declarative.

const { data, error, isLoading } = useQuery(
 // This is our Query Key, which is unique per query so that React
 // Query can manage the cache for us.
 ['user', { userId }],
 
 // This is our Query Function that actually does the data fetching.
 // We can use anything we want: built-in fetch, axios, etc.
 fetchUser({ userId }),
 
 // This query will not execute until the userId exists.
 { enabled: !!userId },
);

Here, we define our query for a user with a Query Key and a Query Function. The Query Key is unique per query, so React Query can manage our cache for us, and if any part of the Query Key changes, the query will automatically fetch new data. In this example, if the user ID changes, the query will fetch new data for that specific user and store that data in the cache.

"There are only two hard things in Computer Science: cache invalidation and naming things" - Phil Karlton

Naming is hard, but caching is harder. React Query gives us caching of server data out of the box with cache invalidation and request deduping. If we use this same query with the same Query Key in another component, it'll check the cache: if the data is already there, React Query will simply return it, avoiding extra network requests!

Additionally, useQuery runs wherever you use it — no more fetching data when a component mounts. You just use the query and display the data when it’s ready. The cherry on top? The query returns status updates by default, so we don’t need to store isLoading in component state all the time.

Three Optimizations We Get With React Query

When we rebuilt Alto Connect, our web app for healthcare providers, we found impactful benefits in using React Query to help us manage server state instead of using Redux. Alto Connect provides instant access to patient and prescription information and enables messaging with the pharmacy. Because healthcare providers need to quickly switch between patients and clinics, caching data is important to provide a delightfully fast experience. Apart from the cache, what other optimizations do we get by using React Query? Below are a few that helped us.

1. Cache invalidation and polling

Once we’ve queried for some data, it gets entered into our cache, so we can utilize that cached data when using the same query in a different component or when fetching new data for a stale query in the background.

React Query is configured with aggressive but sane defaults and considers cached data as stale. Stale queries will always query for new data when the following criteria are met:

  • New instances of the query mount
  • The browser window is refocused
  • The network is reconnected
  • The query is optionally configured with a refetch interval

The fetching for new data when the browser window or tab is refocused is especially useful because the healthcare providers using Alto Connect often switch between multiple browser tabs. If there’s an update to a task, status, or message while they’re checking a patient’s medical chart, it will be immediately reflected in the UI when they come back to Alto Connect.

We also set a refetch interval for our queries involving messages. In the old app, we used WebSockets to enable real-time messaging. Although it’s possible to integrate WebSockets with React Query, we opted for a polling solution instead. Because our app isn’t a real-time messaging application, and we don’t need messages to show up immediately, we set a refetch interval on our messages query to poll for new messages every 30 seconds.

const { data, error, isLoading } = useQuery(
 ['user', { userId }, 'messages'],
 fetchMessages({ userId }),
 {
   // This query is disabled from automatically running if we don’t have a userId
   enabled: !!userId,
 
   // Refetch every 30 seconds
   refetchInterval: 30000,
 
   // Continue to refetch while the tab/window is in the background
   refetchIntervalInBackground: true,
 },
);

This is where the enabled option, which disables a query when it evaluates to false, is particularly powerful. We can pause polling for messages if we route to another page and we know that we won’t make any unnecessary requests if there isn’t a valid userId. We don’t have to worry about closing any socket connections or manually clearing an interval.

2. Prefetching data

Most web apps have some sort of navigation menu, and if you already measure or understand your user behavior, you can prefetch data before users even navigate to a different page. Custom hooks makes it easy to expose a function to prefetch data:

export const usePrefetchUsers = () => {
 const queryClient = useQueryClient();
 const prefetchUsers = async () => {
   await queryClient.prefetchQuery(
     ['users'],
     fetchUsers,
   );
 };
 
 return { prefetchUsers };
};

We prefetch queries when users mouseover or hover over a navigation item so that when they navigate to the next page, the data is either actively being fetched or is already in the cache waiting.

3. Optimistic updates from mutations

We’ve been talking a lot about queries, but what about mutating data? Mutations, in contrast to queries, are imperative. While queries run automatically when we use them, we need to fire a mutation method in order to run mutations. And in order for our user experience to feel snappy, we can optimistically update the cache and then invalidate it after successfully executing the mutation. useMutation gives us some nice side-effect handlers to deal with every stage of the mutation lifecycle. Now when the user sends a message, that new message is immediately reflected in the UI and we keep the data in sync by invalidating the cache and refetching the messages.

const sendMessageMutation = useMutation(
 sendMessage, // this is the actual request
 {
   onMutate: async ({ message }) => {
     // Cancel any outgoing refetches so they don't overwrite our optimistic update
     await queryClient.cancelQueries(['messages']);
 
     // Get the previous messages from the cache. This is a snapshot of
     // what `messageEvents` was before running this mutation.
     const previousMessages = queryClient.getQueryData(['messages']) || [];
 
     // Optimistically update the cache with our new message
     queryClient.setQueryData(
       ['messages'],
       [{ body: message, id: Date.now() }, ...previousMessages],
     );
 
     // Return a context object with the previous messages value
     return { previousMessages };
   },
 
   // If there's an error, we can roll back our optimistic update by grabbing that previous
   // messages snapshot from `context` and updating the cache with that previous value.
   onError: (_err, _variables, context) => {
     queryClient.setQueryData(
       ['messages'],
       [...(context?.previousMessages || [])],
     );
     toaster.toast({ title: 'Message failed to send' });
   },
 
   onSettled: async () => {
     await queryClient.invalidateQueries(['messages']);
   },
 },
);

With Redux, we needed to build out solutions to each challenge of managing server state. React Query, on the other hand, gives us those tools right out of the box. We no longer need to worry about when to invalidate or fetch new data, making the user experience more predictable — and the developer experience more enjoyable.