Skip to main content

Command Palette

Search for a command to run...

Server State vs Client State — React Query / TanStack Query

Day 9 of 40 — React System Design Series

Updated
10 min read
Server State vs Client State — React Query / TanStack Query
R

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.

Hey everyone, Richa here! 👋

movie

Day 9. And today I want to talk about the distinction that changed how I think about state management completely.

Server state vs client state.

For years I put everything in the same bucket. User data from an API? Put it in Redux. UI theme? Put it in Redux. Cart items? Redux. Whether the sidebar is open? Also Redux.

It all lived in one global store and I managed all of it the same way.

Then someone on my team said — "that API data isn't your state. It's a cache. The server owns it. You're just borrowing it."

That one sentence broke my brain a little. And then it all made sense.

movie


The two types of state — and why they're completely different

Client state is state that your app owns. It lives in the browser. The server doesn't know about it and doesn't care.

// Client state examples
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [selectedTheme, setSelectedTheme] = useState('dark');
const [currentStep, setCurrentStep] = useState(1);

These values exist only in your app. They start fresh on every page load. Nobody else needs to know about them. Managing them with useState, useReducer, Zustand, or Context makes total sense.

Server state is data that lives on a server and you're temporarily displaying in your UI.

// Server state examples — you don't own these
const users = await fetch('/api/users');       // the server owns this
const product = await fetch('/api/products/1'); // the server owns this
const orders = await fetch('/api/orders');      // the server owns this

The server owns this data. It can change at any time — another user edits something, a background job updates a record, someone else places an order. Your local copy might be stale the moment you receive it.

This is why server state has problems that client state doesn't:

  • Caching — you fetched it once, you don't want to fetch it again on every render
  • Revalidation — how do you know when your cached copy is stale?
  • Background refetching — should you quietly refresh when the user comes back to the tab?
  • Loading and error states — every fetch has these, and you write them from scratch every time
  • Deduplication — if two components mount at the same time and both need /api/users, do you make two requests?
  • Pagination and infinite scroll — complex async patterns that are painful to build manually

If you've been managing server state with Redux or Zustand — you've been building a half-baked version of React Query by hand.


What React Query (TanStack Query) actually is

React Query is a server state management library. It handles all of the above — caching, revalidation, background refetching, loading states, error states, deduplication — with almost no code from you.

Install it:

npm install @tanstack/react-query

Set it up (one time, at the root):

// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

ReactDOM.createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

Now use it anywhere:

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

function UsersPage() {
  const { data: users, isLoading, isError, error } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(res => res.json()),
  });

  if (isLoading) return <Spinner />;
  if (isError) return <ErrorMessage message={error.message} />;

  return (
    <ul>
      {users.map((user: User) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

That's it. No useState for loading. No useState for error. No useEffect for the fetch. No AbortController. React Query handles all of it.

And the data is cached. If another component on the same page also calls useQuery({ queryKey: ['users'] }) — it gets the same cached data instantly. Zero extra network requests.


The queryKey — the most important concept

The queryKey is how React Query identifies and caches data. Think of it as the cache key.

// These are three different cache entries
useQuery({ queryKey: ['users'], queryFn: fetchAllUsers });
useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId) });
useQuery({ queryKey: ['users', userId, 'posts'], queryFn: () => fetchUserPosts(userId) });

Rules for queryKey:

  • Use an array — always
  • Put the most general item first, most specific last
  • If the query depends on a variable (like userId), include it in the key
  • React Query will automatically refetch when the key changes
// ✅ This automatically refetches when userId changes
function UserProfile({ userId }: { userId: string }) {
  const { data: user } = useQuery({
    queryKey: ['users', userId],    // key includes userId
    queryFn: () => fetchUser(userId),
  });

  return <div>{user?.name}</div>;
}

When userId changes — the key changes — React Query checks the cache for the new key. Cache miss? Fetches. Cache hit? Returns instantly.

No useEffect. No manual dependency tracking. Just the key.


useMutation — handling writes

useQuery is for reading data. useMutation is for creating, updating, and deleting.

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

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

  const createUser = useMutation({
    mutationFn: (newUser: NewUser) =>
      fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newUser),
      }).then(res => res.json()),

    onSuccess: () => {
      // Invalidate the users cache — next time someone reads 'users', it refetches
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  function handleSubmit(data: NewUser) {
    createUser.mutate(data);
  }

  return (
    <form onSubmit={e => { e.preventDefault(); handleSubmit({ name: 'Richa', email: 'r@example.com' }); }}>
      <button type="submit" disabled={createUser.isPending}>
        {createUser.isPending ? 'Creating...' : 'Create User'}
      </button>
      {createUser.isError && <p className="error">{createUser.error.message}</p>}
      {createUser.isSuccess && <p className="success">User created!</p>}
    </form>
  );
}

invalidateQueries is the key pattern. After a successful mutation — mark the related cache as stale. Next time a component reads that data, React Query refetches in the background. The UI stays in sync with the server automatically.


Stale time and cache time — understanding the defaults

React Query has two important time settings:

staleTime — how long until cached data is considered stale (default: 0)

With staleTime: 0, data is immediately stale after fetching. React Query will refetch in the background on every query mount and window focus.

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  staleTime: 1000 * 60 * 5, // 5 minutes — don't refetch for 5 minutes
});

gcTime (garbage collection time) — how long unused data stays in cache (default: 5 minutes)

useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  staleTime: 1000 * 60,      // 1 minute fresh
  gcTime: 1000 * 60 * 10,    // keep in cache for 10 minutes after unmount
});

Setting staleTime appropriately is one of the biggest performance wins. Data that doesn't change often — user profile, product catalogue, config settings — can have a long staleTime. You get instant loads from cache and React Query quietly refreshes in the background.


A practical pattern — custom query hooks

Don't scatter useQuery calls everywhere in your components. Put them in custom hooks:

// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

export function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(res => res.json()) as Promise<User[]>,
    staleTime: 1000 * 60 * 2, // 2 minutes
  });
}

export function useUser(userId: string) {
  return useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()) as Promise<User>,
    enabled: !!userId, // don't run if userId is empty
  });
}

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

  return useMutation({
    mutationFn: (user: NewUser) =>
      fetch('/api/users', {
        method: 'POST',
        body: JSON.stringify(user),
        headers: { 'Content-Type': 'application/json' },
      }).then(res => res.json()),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

Now components are clean:

function UsersPage() {
  const { data: users, isLoading, isError } = useUsers();
  const createUser = useCreateUser();

  if (isLoading) return <Spinner />;
  if (isError) return <p>Something went wrong</p>;

  return (
    <div>
      <button onClick={() => createUser.mutate({ name: 'Test', email: 'test@test.com' })}>
        Add User
      </button>
      <ul>
        {users?.map(user => <li key={user.id}>{user.name}</li>)}
      </ul>
    </div>
  );
}

Components don't know anything about fetching, caching, or invalidation. They just call a hook and render.


What React Query replaces — the before and after

Before React Query — manual server state:

function UsersPage() {
  const [users, setUsers] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    setIsLoading(true);

    fetch('/api/users', { signal: controller.signal })
      .then(res => res.json())
      .then(data => { setUsers(data); setIsLoading(false); })
      .catch(err => {
        if (err.name === 'AbortError') return;
        setError(err.message);
        setIsLoading(false);
      });

    return () => controller.abort();
  }, []);

  // No caching. No deduplication. No background refetch. No retry.
  // Fetches again on every mount. Stale data between navigations.
}

After React Query — 4 lines:

function UsersPage() {
  const { data: users, isLoading, isError } = useUsers();
  // Caching ✅  Deduplication ✅  Background refetch ✅  Retry ✅  Loading state ✅  Error state ✅
}

The before version has zero caching, no deduplication, no background refetch, no retry on failure. The after version gets all of that for free.


When to use React Query vs Zustand/Redux for server data

The answer is almost always: React Query for server data, Zustand/Redux for client state.

They don't compete — they complement each other:

React Query  →  anything that comes from or goes to an API
Zustand      →  UI state, user preferences, local app state
Context      →  auth, theme, locale — rarely-changing globals

Some teams use all three. Some just use React Query + Zustand and skip Redux entirely. Both are valid.

What you should never do is put API data into Redux manually when React Query exists. You're writing caching logic that React Query already solved — and solving it worse.


Quick summary

Feature Manual (useState + useEffect) React Query
Loading state Write it yourself Built-in isLoading
Error state Write it yourself Built-in isError
Caching None Automatic
Deduplication None Automatic
Background refetch None Automatic on window focus
Retry on failure None 3 retries by default
Stale data handling None staleTime config
Cleanup / AbortController Write it yourself Handled internally

Today's takeaway

Server state and client state are fundamentally different things — and they need different tools.

Server state has unique challenges: caching, revalidation, deduplication, background refetching, loading and error states. Managing all of that manually with useState and useEffect is reinventing the wheel — badly.

React Query (TanStack Query) was built specifically for server state. It handles all of that complexity so your components stay clean and your data stays fresh.

The mental shift: stop thinking of API data as state you manage. Think of it as a cache you borrow.

mindblown gif

See you tomorrow for Day 10 — Designing your state layer: where to put what and why. We're tying all of week 2 together.


movie

Day 8 — Redux Toolkit Day 10 — Designing Your State Layer


Part of the React System Design Series — 40 days, one topic per day.

React System Design — Learning in Public

Part 9 of 34

I'm a React developer with 5.5 years of experience who spent 2 years in Lit.js — and slowly forgot how React really works at a senior level. This series is my public journey back. Every day I cover one topic: state management, authentication, performance, system design interviews, testing — the real stuff that separates a mid-level dev from a senior. Written honestly, with real code, for developers at every level.

Up next

Designing Your State Layer — Where to Put What and Why

Day 10 of 40 — React System Design Series