XplormityXplormity
HomeHandbooks
Browse
Xplormity

TLDR developer handbooks for
seasoned developers.

Handbooks

RustNestJSNext.jsGitDockerTypeScriptReactNode.jsDSASQLSystem DesignTailwind CSS

Site

HomeHandbooksAboutPrivacyTerms

Connect

GitHubTwitterLinkedIn

© 2026 Xplormity. All rights reserved.

HandbooksReactState Management — Zustand, Context & TanStack Query

State Management — Zustand, Context & TanStack Query

state-managementzustandtanstack-querycontexthandbook

TL;DR

  • Server state (API data) → TanStack Query (caching, background refetch, optimistic updates).
  • Client state (UI state) → Zustand (simple), Context (small apps), or Jotai (atoms).
  • Don't use one tool for everything — separate concerns.
  • Redux is overkill for most apps in 2026.

Step 1: The State Separation Principle

The biggest mistake React developers make is putting everything in one global store. Server state (API data) and client state (UI state) have fundamentally different characteristics: server state is asynchronous, cached, shared, and can become stale; client state is synchronous, local, and always fresh. The React community learned this the hard way after years of stuffing API responses into Redux — managing cache invalidation, loading states, and background refetching manually is what TanStack Query was built to eliminate. Separating these concerns cuts your state management code by 60-80%.

┌────────────────────────────────────────────┐
│                App State                    │
├──────────────────┬─────────────────────────┤
│   Server State   │     Client State        │
│   (API data)     │     (UI state)          │
├──────────────────┼─────────────────────────┤
│ TanStack Query   │ Zustand / Context       │
│ SWR             │ Jotai / Signals          │
├──────────────────┼─────────────────────────┤
│ • Cached         │ • Theme, sidebar open   │
│ • Background     │ • Form inputs           │
│   refetch        │ • Modal visibility      │
│ • Optimistic     │ • Selected tab          │
│ • Stale-while-   │ • Shopping cart         │
│   revalidate     │                         │
└──────────────────┴─────────────────────────┘

Step 2: TanStack Query — Server State

TanStack Query (formerly React Query) was created because every React app was reinventing the same data-fetching logic: loading states, error handling, caching, refetching on window focus, pagination, optimistic updates. Each team wrote it differently, each implementation had bugs. TanStack Query standardizes all of this into a declarative hook API with intelligent caching (stale-while-revalidate), automatic background refetching, and built-in devtools. It's now the de-facto standard for any React app that talks to an API.

npm install @tanstack/react-query

Basic Usage

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

// Setup
const queryClient = new QueryClient();
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
    </QueryClientProvider>
  );
}

// Fetching data
function UserList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],             // Cache key
    queryFn: () => fetch('/api/users').then(r => r.json()),
    staleTime: 5 * 60 * 1000,       // Data is fresh for 5 minutes
  });

  if (isLoading) return <Spinner />;
  if (error) return <p>Error: {error.message}</p>;

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

Mutations with Optimistic Updates

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

  const addTodo = useMutation({
    mutationFn: (newTodo: { title: string }) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      }).then(r => r.json()),

    // Optimistic update
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previous = queryClient.getQueryData(['todos']);

      queryClient.setQueryData(['todos'], (old: Todo[]) => [
        ...old,
        { id: Date.now(), ...newTodo, pending: true },
      ]);

      return { previous }; // Context for rollback
    },

    onError: (err, newTodo, context) => {
      queryClient.setQueryData(['todos'], context?.previous); // Rollback
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] }); // Refetch
    },
  });

  return (
    <button onClick={() => addTodo.mutate({ title: 'New todo' })}>
      {addTodo.isPending ? 'Adding...' : 'Add Todo'}
    </button>
  );
}

Dependent Queries

function UserPosts({ userId }: { userId: string }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // Only runs when user is available
  const { data: posts } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => fetchPosts(user!.id),
    enabled: !!user, // Don't fetch until user is loaded
  });
}

Infinite Scroll

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

function InfiniteList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: ({ pageParam }) => fetch(`/api/items?cursor=${pageParam}`).then(r => r.json()),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });

  return (
    <div>
      {data?.pages.flatMap(page => page.items).map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
      <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
        {isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'End'}
      </button>
    </div>
  );
}

Step 3: Zustand — Client State

Zustand was created as a reaction to Redux's verbosity. Redux requires actions, action creators, reducers, dispatch, selectors, and a Provider wrapping your app — for what might be a 5-line store. Zustand gives you the same centralized state management with 80% less code: no Provider, no boilerplate, built-in TypeScript support, middleware for persistence and devtools, and surgical re-renders via selectors. By 2026, it surpassed Redux in weekly npm downloads for new projects.

npm install zustand

Basic Store

import { create } from 'zustand';

type CartStore = {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
  total: () => number;
};

const useCartStore = create<CartStore>((set, get) => ({
  items: [],

  addItem: (item) => set((state) => ({
    items: [...state.items, item],
  })),

  removeItem: (id) => set((state) => ({
    items: state.items.filter(i => i.id !== id),
  })),

  clearCart: () => set({ items: [] }),

  total: () => get().items.reduce((sum, item) => sum + item.price, 0),
}));

// Usage — no Provider needed!
function CartIcon() {
  const itemCount = useCartStore(state => state.items.length); // Only re-renders when count changes
  return <span>Cart ({itemCount})</span>;
}

function CartPage() {
  const { items, removeItem, total } = useCartStore();
  return (
    <div>
      {items.map(item => (
        <div key={item.id}>
          {item.name} — ${item.price}
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}
      <p>Total: ${total()}</p>
    </div>
  );
}

Middleware (Persist, DevTools)

import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';

const useAuthStore = create<AuthStore>()(
  devtools(
    persist(
      (set) => ({
        user: null,
        token: null,
        login: async (email: string, password: string) => {
          const response = await fetch('/api/login', {
            method: 'POST',
            body: JSON.stringify({ email, password }),
          });
          const { user, token } = await response.json();
          set({ user, token });
        },
        logout: () => set({ user: null, token: null }),
      }),
      { name: 'auth-storage' } // Key for localStorage
    )
  )
);

Selectors (Performance)

// ❌ Re-renders on ANY store change
const { items, addItem } = useCartStore();

// ✅ Only re-renders when items change
const items = useCartStore(state => state.items);

// ✅ Only re-renders when total changes
const total = useCartStore(state =>
  state.items.reduce((sum, i) => sum + i.price, 0)
);

Step 4: When to Use What

The most common interview question about state management is "how do you decide which tool to use?" The answer comes down to the nature of the data: if it comes from a server and needs caching/refetching, use TanStack Query. If it's purely client-side (theme, cart, UI toggles) and shared across components, use Zustand. If it rarely changes and doesn't need logic (theme, locale), Context is enough. The trap is reaching for a complex solution when useState at the right component level would suffice.

Scenario Solution
API data (users, posts, products) TanStack Query
Auth state (user, token) Zustand with persist
Theme / locale Context (changes rarely)
Form state React Hook Form or local useState
Shopping cart Zustand with persist
Modal/sidebar open state Local useState or Zustand
Complex filtered lists from API TanStack Query + useMemo
Real-time data (WebSocket) Zustand + subscription

Step 5: Context — When It's Enough

React Context was built into the framework itself (not a library) because some values genuinely need to be accessible everywhere without prop drilling: the current theme, authenticated user, locale, feature flags. It's "enough" when the value changes infrequently (theme toggles maybe once per session) and consumers are few. The moment you find yourself splitting context into many pieces to avoid re-renders, or updating it on every keystroke, you've outgrown Context and should reach for Zustand.

// Context is fine for:
// - Themes, locale, auth (changes infrequently)
// - Small apps with few global states
// - Library APIs (compound components)

// Context is BAD for:
// - Frequently updating values (causes all consumers to re-render)
// - Large state objects (any change re-renders everything)

// Optimization: Split contexts
const ThemeContext = createContext<Theme>('light');
const AuthContext = createContext<User | null>(null);
// Now theme changes don't re-render auth consumers

Interview Questions

  1. Why separate server state from client state?

    • They have different lifecycles. Server state is cached, goes stale, needs background refetch. Client state is immediate and local. Using one tool for both creates complexity.
  2. How does TanStack Query's caching work?

    • Data is cached by queryKey. When stale (after staleTime), it shows cached data while refetching in background. gcTime controls when unused cache is garbage collected.
  3. Why Zustand over Redux in 2026?

    • Less boilerplate (no actions/reducers/dispatch), no Provider needed, better TypeScript support, built-in middleware, smaller bundle. Redux is for very large teams needing strict patterns.
  4. How do you prevent unnecessary re-renders with Zustand?

    • Use selectors: useStore(state => state.specificField). Only subscribes to that field — re-renders only when it changes, not on any store update.
React Performance & Patterns

On this page

  • TL;DR
  • Step 1: The State Separation Principle
  • Step 2: TanStack Query — Server State
  • Basic Usage
  • Mutations with Optimistic Updates
  • Dependent Queries
  • Infinite Scroll
  • Step 3: Zustand — Client State
  • Basic Store
  • Middleware (Persist, DevTools)
  • Selectors (Performance)
  • Step 4: When to Use What
  • Step 5: Context — When It's Enough
  • Interview Questions