Using React Query for Efficient Server State Management

Have you ever tried implementing caching on your website while avoiding bloated code, race conditions, and unnecessary re-renders? That’s where React Query comes in. React Query is a powerful data-fetching library for React applications that helps manage server state in a declarative, predictable way.

What Are Server States?

Server states refer to resources stored on your backend and accessed by the UI through API calls. For example, an admin dashboard’s user management module treats user data as server state. Whenever you create or update a user, those changes need to be reflected throughout the UI.

Implementation

Using our user management module example, the website might include the following components:

  • Listing Table: Displays all users.
  • Create User Form: Adds new users.
  • Update User Form: Edits existing user details.
  • Delete User Button: Removes a specific user from the listing table.

In this approach, users are treated as server state to which components—such as the user listing table and create/update user forms—subscribe. Whenever the server state changes, all subscribed components update automatically. For instance, when a user is updated via the update form, the listing table immediately reflects the new information.

Let’s start implementing!

Provider

First, implement the provider and its query client. This enables the React Query utilities in all nested components.

ReactQueryProvider.jsx

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const queryClient = new QueryClient({
  defaultOptions: { queries: { refetchOnWindowFocus: false } },
});

const ReactQueryProvider = ({ children }) => {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
};

export default ReactQueryProvider;

Add this provider to your top-level component like any other provider.

import ReactQueryProvider from "@/providers/ReactQueryProvider";

const AppBase = ({ children }) => {
  return <ReactQueryProvider>{children}</ReactQueryProvider>;
};

export default AppBase;

Subscription

React Query provides the useQuery hook for subscribing to server state. This core feature simplifies data fetching and caching, and the same query can be used by multiple components.

First, create a common file for user state utilities.

users.js

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

export const useUsers = () => {
  return useQuery({
    queryKey: ["users"],
    queryFn: async () => {
      const users = await fetch(
        /* Your API integration to return list of users */
      );
      return users;
    },
  });
};

The query key uniquely identifies the query, and any operations on the query require this key.

Here’s an example of a simple table subscribed to the user state.

UserListingTable.jsx

import { useUsers } from "./users";

const UsersListingTable = () => {
  const { data: users, isLoading, isError, error } = useUsers();

  // Handling loading state
  if (isLoading) return <div>Loading...</div>;

  // Handling error state
  if (isError) return <div>Error: {error.message}</div>;

  // Table
  return (
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>Name</th>
        </tr>
      </thead>
      <tbody>
        {users.map((user) => (
          <tr key={user.id}>
            <td>{user.id}</td>
            <td>{user.name}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
};

export default UsersListingTable;

Similar to how useState works, this table component will re-render only when the user data changes.

Mutation

React Query provides the useMutation hook to modify the server state. For user state management, you will need mutations to create, update, and delete users.

Let’s add a mutation to create a user in our common file.

users.js

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

export const useCreateUser = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: async (data) => {
      const users = await fetch(
        /* Your API integration to create users */,
        data
      );
      return users;
    },
    onSuccess: () => {
      // Invalidate the "users" query to refetch updated data
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });
};

To use this mutation in the create user form:

import { useCreateUser } from "./users";

const CreateUserForm = () => {
  const createUser = useCreateUser();

  // Form onSubmit function
  const onSubmit = async (data) => {
    createUser.mutateAsync(data);
  };

  return <>{/* Form code */}</>;
};

export default CreateUserForm;

Invalidating the “users” query automatically causes it to refetch, ensuring that all subscribed components (such as the user listing table) reflect the latest changes.

Similar mutations can be created to update or delete users.

Bonus

This approach can eliminate the need for a global store to track server state. Since multiple components subscribe to the same query, a global store becomes unnecessary. Additionally, you can leverage query client options and useQuery settings—like refetchInterval and refetchOnMount—to optimize API calls for different scenarios.

Now that you’ve been introduced to caching and server state management with React Query, the world is your oyster. Dive in and experiment!