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
-
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.
-
How does TanStack Query's caching work?
- Data is cached by
queryKey. When stale (afterstaleTime), it shows cached data while refetching in background.gcTimecontrols when unused cache is garbage collected.
- Data is cached by
-
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.
-
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.
- Use selectors: