State Management06-02-202512 min read

React Query for Server State Management

A practical guide to managing server state in React with React Query, covering queries, mutations, caching, invalidation, and how it fits with other state tools.


React Query for Server State Management


Most frontend bugs do not come from button clicks or simple UI state. They come from data that lives on a backend and has to be fetched, cached, updated, and kept in sync across many screens. This is server state. Trying to manage it with ad hoc useEffect calls or a general purpose state library quickly becomes painful.


React Query (TanStack Query) is built specifically for server state. It handles fetching, caching, refetching, deduplication, and synchronization for you so that your components focus on rendering.


This guide explains how to use React Query in real applications, how to think in terms of server state, and how it fits alongside tools like Redux or Zustand.




1. Server State versus Client State


Before using React Query, you need a clear mental model for server state.


Server state:


  • lives on a backend
  • can change without your app knowing about it
  • is shared across multiple screens
  • needs caching and refetching rules
  • often has pagination, filters, or sorting

Client state:


  • lives entirely in the browser
  • models UI or local business logic
  • includes things like modals, filters, wizard steps, or form drafts

React Query is for server state, not everything.


A common pattern in modern React apps:


  • React Query for server state
  • Redux or Zustand for client state and workflows
  • local useState for small view specific details



2. Setting Up React Query


At the root of your app you create a QueryClient and wrap your tree with QueryClientProvider.


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

const queryClient = new QueryClient();

export function AppRoot({ children }: { children: ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

You usually do this once near your router or layout component.


From this point on, any component can call useQuery or useMutation.




3. Writing Your First Query


A query represents a read operation against your backend. You give it a unique key and an async function.


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

async function fetchUser(userId: string) {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) throw new Error("Failed to fetch user");
  return res.json() as Promise<{ id: string; name: string }>;
}

export function useUser(userId: string) {
  return useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
    staleTime: 60 * 1000 // 1 minute
  });
}

Usage in a component:


function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, isError } = useUser(userId);

  if (isLoading) return <p>Loading...</p>;
  if (isError) return <p>Could not load user.</p>;

  return <h1>{data!.name}</h1>;
}

Key points:


  • queryKey identifies the data. Keys drive caching and invalidation.
  • queryFn is responsible for fetching.
  • status flags like isLoading, isError, and isSuccess keep your UI logic straightforward.



4. Controlling Staleness and Refetching


React Query treats data as stale or fresh. This determines whether it refetches automatically.


Important options:


  • staleTime: how long data stays fresh
  • cacheTime: how long unused data stays in memory
  • refetchOnWindowFocus: refetch when the window gains focus
  • refetchOnReconnect: refetch on network reconnect

Example for a dashboard:


useQuery({
  queryKey: ["projects"],
  queryFn: fetchProjects,
  staleTime: 5 * 60 * 1000, // 5 minutes
  refetchOnWindowFocus: false
});

For user profiles that rarely change, you can give a long staleTime. For live data like trading dashboards, you keep it short or refetch on an interval.




5. Mutations and Invalidation


Mutations represent writes to the backend. You use them to create, update, or delete data.


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

async function updateUser(input: { id: string; name: string }) {
  const res = await fetch(`/api/users/${input.id}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name: input.name })
  });

  if (!res.ok) throw new Error("Failed to update user");
  return res.json() as Promise<{ id: string; name: string }>;
}

export function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateUser,
    onSuccess: (data) => {
      // Invalidate or update cached queries
      queryClient.invalidateQueries({ queryKey: ["user", data.id] });
    }
  });
}

Usage in a component:


const updateUser = useUpdateUser();

function onSave(name: string) {
  updateUser.mutate({ id: userId, name });
}

Invalidation tells React Query that some cached queries are now stale and should be refetched on next render or when the user revisits that screen.




6. Optimistic Updates


Optimistic updates provide instant feedback in the UI while the network request is still pending.


Example for a todo list:


function useUpdateTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateTodoOnServer,
    async onMutate(updatedTodo) {
      await queryClient.cancelQueries({ queryKey: ["todos"] });

      const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);

      queryClient.setQueryData<Todo[]>(["todos"], (old) =>
        (old ?? []).map((todo) =>
          todo.id == updatedTodo.id ? { ...todo, ...updatedTodo } : todo
        )
      );

      return { previousTodos };
    },
    onError(_error, _variables, context) {
      if (context?.previousTodos) {
        queryClient.setQueryData(["todos"], context.previousTodos);
      }
    },
    onSettled() {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    }
  });
}

This pattern:


1. Cancels ongoing fetches.

2. Applies a temporary optimistic update.

3. Rolls back if the mutation fails.

4. Refetches the canonical data after completion.




7. Transforming Data With select


Sometimes you want to shape data before it reaches components. The select option lets you do that inside the query.


type Project = { id: string; name: string; active: boolean };

function useActiveProjects() {
  return useQuery({
    queryKey: ["projects"],
    queryFn: fetchProjects,
    select: (projects: Project[]) => projects.filter((p) => p.active)
  });
}

The component receives only the filtered list, not the entire dataset. This keeps the view logic simpler and avoids repeating the same map or filter everywhere.




8. Error and Loading States in Real Screens


In real applications you often reuse patterns like:


if (isLoading) {
  return <Spinner />;
}

if (isError) {
  return <ErrorMessage />;
}

You can extract these into reusable wrappers, or use Suspense based integration if your app is already structured around Suspense boundaries.


Example with a small wrapper component:


function QueryBoundary<T>({
  query,
  children
}: {
  query: { isLoading: boolean; isError: boolean };
  children: React.ReactNode;
}) {
  if (query.isLoading) return <Spinner />;
  if (query.isError) return <ErrorMessage />;
  return <>{children}</>;
}

Then:


const userQuery = useUser(userId);

return (
  <QueryBoundary query={userQuery}>
    <UserProfileView user={userQuery.data!} />
  </QueryBoundary>
);

This keeps screens consistent and easier to scan.




9. How React Query Fits With Redux or Zustand


React Query does not replace Redux or Zustand. They solve different problems.


Use React Query for:


  • server data
  • query caching
  • synchronization with the backend

Use Redux or Zustand for:


  • client side workflows
  • global filters
  • feature flags
  • complex UI or domain logic

Incorrect pattern:


  • Storing server responses inside Redux or Zustand.
  • Manually implementing caching or refetching there.

Correct pattern:


  • Let React Query own server data.
  • Derive client state from that data where needed.
  • Use Redux or Zustand only for state that has no canonical representation on the backend.

Example:


  • React Query: useProjects for fetching projects.
  • Zustand: useFilterStore for current filter selection.
  • Component: uses both to show a filtered list.



10. Common Pitfalls and How to Avoid Them


Pitfall 1: Using React Query for UI only state


Do not store modals, toasts, or form drafts in React Query. Use local state or a client state store.


**Pitfall 2: Overusing refetch manually**


If you set staleTime and cache configuration correctly, you should not need to call refetch everywhere. Prefer invalidation through invalidateQueries.


**Pitfall 3: Writing large anonymous useQuery calls in components**


Prefer small custom hooks that wrap queries:


function useProjects() {
  return useQuery({ ... });
}

This keeps components cleaner and makes refactoring easier.


Pitfall 4: Ignoring error handling


Add clear error UI and log errors where appropriate. Treat isError as a first class state.




Final Thoughts


React Query gives you a dedicated model for server state that aligns with how real backends behave. By treating queries as cached resources, mutations as explicit writes, and invalidation as the signal that data is outdated, you can keep server data logic out of components and out of general purpose state stores.


Paired with a clear separation between server state and client state, React Query becomes a central tool for building React applications that stay predictable as they grow.