Managing Server State in React Application using React-Query

Anujeet's avatar

Anujeet

When building React applications, managing data efficiently is essential—especially when dealing with server state. Server state refers to data fetched from external sources, which needs to stay synchronized with the client. Traditionally, developers rely on combination of useEffect and useState to fetch data and manage the local state. While this approach works for simple use cases, it quickly becomes inadequate as your application scales and server state management grows more complex.

Why Keeping Server State Up-to-Date is Hard

The challenge with server state isn’t just fetching data; it’s keeping that data consistent and up-to-date over time. You need to handle caching, refetching, background updates, and error handling to ensure your app runs smoothly. As these tasks grow, managing them manually can lead to bloated code, unexpected bugs, and significant overhead.

This is where React Query comes in. Though often associated with data fetching, React Query is much more than that—it’s an asynchronous state management library designed to tackle the unique challenges of server state. It simplifies the process by offering solutions for caching, synchronization, and error handling, allowing developers to focus on the core logic of their applications rather than the complexity of state management.

Traditional Data Fetching in React

To illustrate the complexities of managing server state manually, let’s look at the traditional way of fetching data in React using useEffect and useState:

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    const fetchTodos = async () => {
      try {
        const response = await fetch('/api/todos');
        if (!response.ok) throw new Error('Error while fetching todos');
        const data = await response.json();
        setTodos(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setIsLoading(false);
      }
    };
    fetchTodos();
  }, []);
 
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
 
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

While this approach works, it introduces several challenges as your app grows:

  • Multiple states: We need useState to track loading, error, and success states separately.

  • State synchronization: When server state changes, our client-side state doesn't automatically sync with the server, requiring manual effort to keep the app up to date.

  • Manual fetching logic: You must explicitly manage when to fetch data, handle retries, and deal with side effects that come along with it.

  • No caching or deduplication: Fetching the same data multiple times from different components can create inconsistent states and duplicate requests to the server. You will need to implement your own caching and deduplication strategies.

React Query: Simplifying Server State Management

React Query streamlines server state management by abstracting away the repetitive tasks of managing loading, error, and success states. It automatically handles caching, stale data detection, background refetching, and deduplication, allowing you to focus on building features instead of managing these asynchronous states.

Here’s how the same data-fetching logic looks when using React Query:

import { useQuery } from '@tanstack/react-query';
 
function TodoList() {
  const { data, error, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: () => fetch('/api/todos').then((res) => res.json()),
  });
 
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
 
  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

With React Query:

  • You no longer need multiple useState for managing loading, error, and data.

  • Caching and refetching are handled automatically, without dealing with side effects in useEffect.

  • There is no need to worry about re-fetching or duplicated requests, React Query handles them intelligently, keeping your app efficient and responsive.

Setting up React Query

To get started with React Query, you will need to configure a few core components. This section will guide you through setting up a sample app using React Query to manage server state efficiently.

Step 1: Install React Query

npm install @tanstack/react-query

Step 2: Set up Query Client

The QueryClient acts as the central cache for all your queries. You will create an instance of QueryClient and wrap your app with the QueryClientProvider to make this cache available throughout your component tree.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 
const queryClient = new QueryClient();
 
export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  );
}

Step 3: Using Query Key and Query Function

Next, we need to understand how queries work. React Query operates based on two essential parameters: the query key and the query function.

  • Query Key: A unique identifier for your query. It can be a simple string or a more complex array (e.g. with parameters) for handling dynamic dependencies.

  • Query Function: The function that fetches data. This is typically an API call, returning a promise that resolves to the desired data.

import { useQuery } from '@tanstack/react-query';
 
function TodoList() {
  const { data, error, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: () => fetch('/api/todos').then((res) => res.json()),
  });
 
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
 
  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

In React Query, each query is associated with a query key and its corresponding query function. When a query runs:

  1. React Query first checks the cache using the provided query key.

  2. If the data is cached, it serves the cached data to avoid unnecessary requests.

  3. If there’s no cache hit, it will run the query function to fetch new data.

  4. The fetched data is then stored in the cache under the query key for future use.

Deduplication and Query Observers

One of the powerful features of React Query is Deduplication. In an application multiple components can request the same data, but React Query consolidates them into a single request, updating all components once the data is fetched. This optimization avoids the redundant network request for the same data, leading to improved performance and predictibility in the application. Under the hood React Query uses Query Observers, which synchronizes the values from the cache back to the react components.

In this example, we have two components that need to fetch the same user data. Instead of triggering two separate network requests, React Query deduplicates them behind the scenes.

import { useQuery } from '@tanstack/react-query';
 
const fetchTodos = async () => {
  const response = await fetch('/api/todos');
  return response.json();
};
 
function AllTodos() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos;
  });
 
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
 
  return <div>{data.name}</div>;
}
 
function CompletedTodos() {
  const { data, isLoading, error } = useQuery('todos', fetchTodos);
 
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
 
  return <div>{data.name} {data.status}</div>;
}
 
function App() {
  return (
    <div>
      <AllTodos />
      <CompletedTodos />
    </div>
  );
}

In the above example:

  • Both components use useQuery with the same queryKey (['todos']) and queryFn (fetchTodos).

  • React Query automatically deduplicates the two requests. If both components mount simultaneously, only one request will be made, and both components will share the same result.

  • Once the data is cached, any subsequent render will use the cached data instead of triggering a new fetch.

  • This ensures that we get maximum predictibility with performance optimization.

Lifecycle States in React Query

React Query simplifies the data fetching lifecycle by handling different states automatically, reducing the need for manual state management and providing an easy way to track the status of each query. Let’s take a closer look at how the lifecycle works and the significance of each state.

At its core, React Query returns a promise when a query is made. This promise handles the various stages of a request—like loading, success, error, and even background refetching—all in a seamless way. The query lifecycle states help developers understand the current status of the data fetching process, and React Query automatically exposes these states through its useQuery hook.

Here’s an example where we demonstrate all the significant states that React Query manages for you:

import { useQuery } from '@tanstack/react-query';
 
const fetchPosts = async () => {
  const response = await fetch('/api/posts');
  return response.json();
};
 
function PostsComponent() {
  const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });
 
  if (isLoading) return <p>Loading...</p>;
  if (isError) return <p>Error: {error.message}</p>;
 
  return (
    <div>
      <h2>Posts</h2>
      {isFetching && <p>Updating...</p>}
      <ul>
        {data.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <button onClick={refetch}>Refetch Posts</button>
    </div>
  );
}
  • isLoading: This state is true while the data is being fetched for the first time. It's the equivalent of a "loading" indicator.

  • isError: This state is true when there is an error during the request. The error object provides more details about what went wrong.

  • isSuccess: Indicates that the query successfully fetched data. The data is now available to be rendered.

  • isFetching: This state tracks any fetch action, including background refetching. It remains true if new data is being fetched in the background, even when old data is still available.

  • isStale: Represents outdated data. React Query marks data as "stale" when it thinks the cache is outdated and might need to be refetched.

  • refetch: A method provided by React Query to manually trigger a refetch, even if the data is still fresh.

By managing the full lifecycle of a query, React Query removes the complexity of asynchronous state management, allowing you to focus on building your application. Whether you're dealing with initial loading, background refetching, or error handling, React Query makes it easy to understand and respond to the state of your data.

Data Synchronization with Server State

Server state is dynamic—it changes over time. If our cached data doesn’t stay up-to-date, it becomes stale, resulting in an outdated user experience. By default, React Query caches all data and serves subsequent requests from the cache once the data is loaded. However, when dealing with constantly changing server state, the cache needs to be invalidated to ensure the application maintains a fresh state in sync with the server. React Query makes cache invalidation seamless by offering built-in properties that automatically keep your data fresh and in sync with the server, without requiring developers to handle cache invalidation manually.

How React Query Syncs Data Using Stale Time

React Query uses the concept of stale time to determine whether data in the cache is fresh or stale. Here’s the flow:

  1. Fresh Data: When data is first fetched, it is considered fresh. Within the stale time (e.g., staleTime: 60000ms i.e. 1 minute), React Query will not attempt to refetch this data.

  2. Becomes Stale: After the stale time passes, the data is marked as stale, and React Query will refetch the data the next time it is accessed (e.g., when the component mounts again or on a manual trigger).

  3. Sync with Server State: While the data is fresh, React Query skips fetching. Once stale, it checks with the server to ensure the client always sees the most up-to-date data.

const { data, isLoading, isStale, refetch } = useQuery(['dataKey'], fetchData, {
  staleTime: 60000,
});

In the above example staleTime is set to 60000ms, the data in cache is considered fresh up until this time period.

  • When Data is Fresh (isStale = false): No refetching occurs, React Query serves the data directly from the cache.

  • When Data is Stale (isStale = true): The data is refetched on access, ensuring it's synced with the server.

Query Refetch using Triggers

To ensure synchronization without manual intervention, React Query provides certain automatic refetch triggers:

  • Window Focus: Automatically refetch data when the user refocuses the browser window.

  • Reconnect: Refetch when the user regains network connectivity after being offline.

  • Interval: Configure React Query to refetch data at a specified interval (e.g., every minute) to maintain real-time synchronization.

Example:

const { data, isStale, refetch } = useQuery('dataKey', fetchData, {
  staleTime: 60000,
  refetchOnWindowFocus: true,
  refetchOnReconnect: true,
  refetchInterval: 30000,
});
 
// Manual refetch trigger
<button onClick={() => refetch()}>Refetch Data</button>;

In this example:

  • staleTime: 60000 ensures that the data will be considered fresh up until this time. Any data request post this time period will trigger a refetch.

  • refetchOnWindowFocus and refetchOnReconnect set to true, will ensure that React Query triggers a refetch when the window is refocused or the network reconnects.

  • refetchInterval: 30000 will trigger a refetch at an interval of 30s to maintain real-time synchronization with server.

  • You can even manually trigger refetch by invoking the refetch method.

Explicit cache Invalidation

React Query’s caching mechanism automatically invalidates stale data and refetches it when necessary. However, sometimes you need to explicitly invalidate the cache when you know that the server state has changed (e.g., after a mutation). React Query provides functions like invalidateQueries to force a refetch when necessary, ensuring that your app reflects the latest data.

const queryClient = useQueryClient();
queryClient.invalidateQueries('todos');

By calling invalidateQueries, you can ensure that the cached data is refreshed, prompting React Query to refetch it from the server.

Conclusion

In this article, we explored the challenges of traditional data-fetching methods and how React Query addresses them in a robust, declarative way. We have seen how React Query simplifies complex tasks like caching, deduplication, and background updates, keeping your client state synced effortlessly with the server state. By automating these processes, React Query allows you to focus on building features without worrying about the complexities that come along with asynchronous state management.

With the knowledge you have gained, you are now ready to implement React Query in your applications to build scalable, performant, and maintainable features. Happy coding!

References