Going Deeper: Production ReactPro· 45 min read

Server State with TanStack Query

Fetch, cache and keep server data fresh with one hook — the modern replacement for hand-written useEffect fetching.

What you will learn

  • Explain why server state is different from local state
  • Fetch data with the useQuery hook
  • Send changes with useMutation

Server state is a different beast

You already fetch data with useEffect + fetch + three pieces of state (loading, error, data). That works, but real apps need much more: caching so you do not re-fetch the same data, refetching when the user comes back to the tab, retrying failed requests, and keeping many screens in sync. Writing all that by hand for every screen is a lot of repetitive, bug-prone code.

Server state — data that lives on a server and you only borrow a copy of — is genuinely different from local state (a counter, a form field). TanStack Query (the library is imported as @tanstack/react-query, and people often call it React Query) is built specifically for server state. It does the caching, refetching and retrying for you, so your components stay tiny.

One-time setup

You install the library and wrap your app once in a provider that holds the cache. This is real project code, shown with its Output.

Install React Query
npm install @tanstack/react-query
Provide the query client at the top of the app
// main.jsx — wrap the app once
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

// <QueryClientProvider client={queryClient}>
//   <App />
// </QueryClientProvider>

The QueryClient is the cache — the shared memory where fetched data is stored. Wrapping <App /> in <QueryClientProvider> makes that cache available to every component, the same way a context provider shares a value. You do this once and forget about it.

Reading data with useQuery

Now any component can fetch data with the useQuery hook. Compare how much smaller this is than the useEffect version.

Fetch, cache and render with one hook
import { useQuery } from '@tanstack/react-query';

function fetchUsers() {
  return fetch('https://jsonplaceholder.typicode.com/users').then(r => r.json());
}

function UserList() {
  const { data, isLoading, isError } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
  });

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

  return (
    <ul>
      {data.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  );
}

The two things you give useQuery and the three things you get back:

  1. queryKey: ['users'] is a unique name for this piece of data. React Query uses it as the cache label — ask for ['users'] again anywhere in the app and it serves the cached copy instead of re-fetching.
  2. queryFn: fetchUsers is the function that actually fetches. React Query calls it for you, and will retry it automatically if it fails.
  3. You get back isLoading, isError and data ready to use — no useState, no useEffect, no manual flags. The three-state pattern is handled for you.

Note: Output: Briefly Loading…, then the list of user names. Navigate away and back, and the list appears instantly from the cache while React Query quietly refetches in the background to keep it fresh.

Changing data with useMutation

Reading data is useQuery; changing it (create, update, delete) is useMutation. After a successful change you tell React Query to refetch the affected data so the screen updates.

Send a change with useMutation, then refresh the list
import { useMutation, useQueryClient } from '@tanstack/react-query';

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

  const mutation = useMutation({
    mutationFn: (newUser) =>
      fetch('/api/users', { method: 'POST', body: JSON.stringify(newUser) }),
    onSuccess: () => {
      // refetch the 'users' list so the UI updates
      queryClient.invalidateQueries({ queryKey: ['users'] });
    }
  });

  return (
    <button onClick={() => mutation.mutate({ name: 'Asha' })}>
      Add user
    </button>
  );
}

The flow: mutationFn is the function that sends the change to the server (here a POST). Clicking the button calls mutation.mutate({ name: 'Asha' }) to run it. When it succeeds, onSuccess runs queryClient.invalidateQueries({ queryKey: ['users'] }) — this marks the cached ['users'] list as stale, so React Query automatically refetches it and every screen showing that list updates. You never manually push the new user into state.

Tip: In MERN this points at your own Node/Express API instead of a public URL — exactly like the plain-fetch lesson, but with caching, retries and background refresh handled for you.

Watch out: React Query replaces useEffect fetching for server data only. Keep useState/useReducer for local UI state (form text, a toggle, the current tab). The two solve different problems.

Q. What is the queryKey used for in useQuery?

Answer: The queryKey uniquely identifies a piece of server data in the cache, so React Query can dedupe, cache and invalidate it.

✍️ Practice

  1. Rewrite the JSONPlaceholder users fetch using useQuery instead of useEffect, with isLoading and isError.
  2. Add a second useQuery for /posts and confirm each has its own queryKey.

🏠 Homework

  1. Add a useMutation that “adds” an item and uses invalidateQueries to refresh the list. Write two sentences on what React Query did for you that useEffect did not.
Want to learn this with a mentor?

CodingClave runs guided, project-based training (28-day, 45-day & 6-month batches).

Explore Training →