Skip to content
← All posts
0514 min read

State Management in 2026: TanStack Query vs Zustand vs Context

  • frontend-development
  • typescript
  • javascript
State Management in 2026: TanStack Query vs Zustand vs Context — by Chirag Mehta
State Management in 2026: TanStack Query vs Zustand vs Context

Every few months someone on my team opens a PR that adds Redux Toolkit to fetch a list of orders. I close it, add a useQuery hook instead, and the diff shrinks by 200 lines. This isn't a knock on Redux — it's a symptom of a bigger problem: most React developers still reach for "a state manager" as if state were one thing. It isn't. And picking the wrong tool for the kind of state you actually have is where 90% of the re-render bugs, stale-cache headaches, and "why is this component re-rendering four times" Slack threads come from.

I’ve shipped production apps with all three tools this year — TanStack Query, Zustand, and plain Context — often in the same codebase, on purpose. This is the guide I wish existed when I was still trying to force Redux to do a fetch library’s job. We’ll build a real feature (an authenticated dashboard with a refresh-token flow and RBAC-gated UI) and use it to show exactly where each tool belongs.

Server state vs client state — why they need different tools

Server state vs client state — why they need different tools
Server state vs client state — why they need different tools

The single most useful mental model in React state management is this split:

  • Server state: data that lives on a server, that you don’t own, that can go stale, and that other people/processes can change without your app knowing. Examples: a user’s profile, a list of orders, a product catalog.
  • Client state: data that only exists in the browser, that your app fully owns. Examples: is the sidebar open, what’s the current form value, is dark mode on, is the user authenticated (the boolean, not the token-refresh mechanics).

Server state has problems client state doesn’t: caching, deduplication, background refetching, pagination, race conditions between requests, and invalidation when a mutation happens elsewhere. Client state has problems server state doesn’t: it needs to be simple, synchronous, and cheap to update on every keystroke or click.

The mistake I see constantly is putting server state into a general-purpose store (Redux, Zustand, or a giant Context) and hand-rolling loading flags, error flags, and manual useEffect refetch logic. You end up rebuilding a worse version of TanStack Query, badly, forever.

My working rule for 2026:

  • If it came from an API → TanStack Query.
  • If it’s UI/app state shared across distant components → Zustand.
  • If it’s a small, rarely-changing value scoped to a subtree → Context.

Let’s build each piece.

TanStack Query for API data (caching, refetching, optimistic updates)

TanStack Query (formerly React Query) isn’t a state manager in the Redux sense — it’s an async state cache. You give it a key and a fetcher function, and it handles caching, deduplication, background refetch, retries, and stale-data invalidation for you.

Basic setup

// src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // data is "fresh" for 60s, no refetch on remount
retry: 1,
refetchOnWindowFocus: true, // good default for dashboards
},
mutations: {
retry: 0,
},
},
});
// src/main.tsx
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './lib/queryClient';
import App from './App';
export function Root() {
return (
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
}

A real fetch hook, with caching and refetching

// src/features/orders/useOrders.ts
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/apiFetch';
type Order = {
id: string;
total: number;
status: 'pending' | 'shipped' | 'delivered';
};
export function useOrders(status?: Order['status']) {
return useQuery({
// the key includes every input the query depends on —
// changing `status` automatically fetches + caches separately
queryKey: ['orders', { status }],
queryFn: () => apiFetch<Order[]>(`/api/orders${status ? `?status=${status}` : ''}`),
staleTime: 30 * 1000,
});
}
// src/features/orders/OrdersList.tsx
import { useOrders } from './useOrders';
export function OrdersList() {
const { data: orders, isLoading, isError, error, refetch, isFetching } = useOrders('pending');
  if (isLoading) return <p>Loading orders…</p>;
if (isError) return <p>Failed to load orders: {(error as Error).message}</p>;
  return (
<div>
<button onClick={() => refetch()} disabled={isFetching}>
{isFetching ? 'Refreshing…' : 'Refresh'}
</button>
<ul>
{orders!.map((o) => (
<li key={o.id}>{o.id} — ${o.total}</li>
))}
</ul>
</div>
);
}

Note what you didn’t write: no useState for loading/error, no useEffect with a cleanup flag to avoid setting state on an unmounted component, no manual cache object. That's the entire pitch.

Optimistic updates

This is where TanStack Query earns its keep on real production UIs — updating the UI instantly, then rolling back if the server disagrees.

// src/features/orders/useUpdateOrderStatus.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/apiFetch';
type Order = { id: string; total: number; status: 'pending' | 'shipped' | 'delivered' };
export function useUpdateOrderStatus() {
const queryClient = useQueryClient();
  return useMutation({
mutationFn: (vars: { id: string; status: Order['status'] }) =>
apiFetch(`/api/orders/${vars.id}`, {
method: 'PATCH',
body: JSON.stringify({ status: vars.status }),
}),
    // fires before the request goes out
onMutate: async (vars) => {
// stop any in-flight refetch from clobbering our optimistic write
await queryClient.cancelQueries({ queryKey: ['orders'] });
      // snapshot the previous cache so we can roll back on failure
const previousOrders = queryClient.getQueryData<Order[]>(['orders', { status: 'pending' }]);
      // write the optimistic update directly into the cache
queryClient.setQueryData<Order[]>(['orders', { status: 'pending' }], (old) =>
old?.map((o) => (o.id === vars.id ? { ...o, status: vars.status } : o))
);
      return { previousOrders };
},
    // rollback using the snapshot from onMutate
onError: (_err, _vars, context) => {
if (context?.previousOrders) {
queryClient.setQueryData(['orders', { status: 'pending' }], context.previousOrders);
}
},
    // always resync with the server after the dust settles
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['orders'] });
},
});
}
// usage
const { mutate: updateStatus, isPending } = useUpdateOrderStatus();
<button disabled={isPending} onClick={() => updateStatus({ id: order.id, status: 'shipped' })}>
Mark as shipped
</button>

The user sees the row flip to “shipped” instantly. If the PATCH fails, it snaps back and onError can trigger a toast. This pattern alone eliminates a huge chunk of the "spinner soup" UX you see in apps that fetch-and-wait for every action.

Zustand for lightweight global client state

Once server state is off your plate, what’s left is genuinely small: sidebar collapsed state, active theme, a multi-step wizard’s current step, feature flags toggled client-side, or — the one we’ll wire up below — auth state (is the user logged in, what’s their role).

Zustand gives you a store with zero boilerplate and, critically, selective subscriptions — components only re-render when the specific slice they read changes.

// src/stores/useUiStore.ts
import { create } from 'zustand';
type UiState = {
isSidebarOpen: boolean;
theme: 'light' | 'dark';
toggleSidebar: () => void;
setTheme: (theme: 'light' | 'dark') => void;
};
export const useUiStore = create<UiState>((set) => ({
isSidebarOpen: true,
theme: 'light',
toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
setTheme: (theme) => set({ theme }),
}));
// src/components/Sidebar.tsx
import { useUiStore } from '../stores/useUiStore';
export function Sidebar() {
// selector: this component only re-renders when isSidebarOpen changes,
// NOT when theme or anything else in the store changes
const isOpen = useUiStore((s) => s.isSidebarOpen);
const toggleSidebar = useUiStore((s) => s.toggleSidebar);
  return (
<aside className={isOpen ? 'sidebar open' : 'sidebar closed'}>
<button onClick={toggleSidebar}>{isOpen ? 'Collapse' : 'Expand'}</button>
{/* nav items */}
</aside>
);
}
// src/components/ThemeToggle.tsx — a totally separate component
import { useUiStore } from '../stores/useUiStore';
export function ThemeToggle() {
const theme = useUiStore((s) => s.theme);
const setTheme = useUiStore((s) => s.setTheme);
  return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Switch to {theme === 'light' ? 'dark' : 'light'} mode
</button>
);
}

ThemeToggle never re-renders when the sidebar opens or closes, and vice versa — because each component subscribes to a narrow slice, not the whole store. That's the property Context struggles to give you without extra work, which brings us to the next section.

When plain Context still wins (and when it causes re-render pain)

When plain Context still wins (and when it causes re-render pain)
When plain Context still wins (and when it causes re-render pain)

Context is not “bad.” It’s the right tool for values that are genuinely global-but-rarely-changing and don’t need external subscription logic: the current locale, a design-system theme object, an already-resolved auth session passed down to a subtree, dependency-injection-style things (an SDK client instance, a feature-flag reader).

Context becomes a problem the moment you put frequently changing state in it, because every consumer of that context re-renders on every change — there’s no selector mechanism built in.

The problematic version

// BAD: one context holding everything, including fast-changing values
type AppContextValue = {
user: { id: string; name: string; role: 'admin' | 'member' } | null;
notifications: Notification[];
cursorPosition: { x: number; y: number }; // updates on every mousemove!
};
const AppContext = createContext<AppContextValue | null>(null);
export function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AppContextValue['user']>(null);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
  useEffect(() => {
const handler = (e: MouseEvent) => setCursorPosition({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);
  // every mousemove creates a new object reference here,
// which re-renders EVERY consumer of AppContext, including
// components that only care about `user`
const value = { user, notifications, cursorPosition };
  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

Any component reading useContext(AppContext) just to get user.name re-renders on every pixel of mouse movement. This is the classic "why is my whole app janky" bug report.

The fix: split by change frequency, not by domain

// GOOD: split contexts by how often the value changes.
// Fast-changing, narrowly-consumed state doesn't belong in Context at all —
// it goes in a ref, local state, or (if genuinely global) Zustand.
const UserContext = createContext<AppContextValue['user']>(null);
export function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AppContextValue['user']>(null);
// user changes rarely: on login, logout, or profile edit
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}
export function useUser() {
return useContext(UserContext);
}
// cursor tracking moves to a local hook used only by the one
// component that actually needs it — it never touches Context
function useCursorPosition() {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);
return pos;
}

Rule of thumb: if a value changes more than once every few seconds, or if only a couple of components need it, Context is the wrong home for it. Context wins when the value is stable and everyone underneath genuinely needs it — theme, locale, an injected client.

Wiring TanStack Query into an existing refresh-token/RBAC flow

Wiring TanStack Query into an existing refresh-token/RBAC flow
Wiring TanStack Query into an existing refresh-token/RBAC flow

This is where the three tools meet in a real app. Auth identity (is logged in, role) is small and global → Zustand. Auth data fetching (refreshing the access token, calling protected endpoints) → TanStack Query’s fetcher layer. RBAC gating in the UI reads the Zustand store.

The auth store

// src/stores/useAuthStore.ts
import { create } from 'zustand';
type Role = 'admin' | 'member' | 'guest';
type AuthState = {
accessToken: string | null;
role: Role | null;
isAuthenticated: boolean;
setSession: (accessToken: string, role: Role) => void;
clearSession: () => void;
};
export const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
role: null,
isAuthenticated: false,
setSession: (accessToken, role) =>
set({ accessToken, role, isAuthenticated: true }),
clearSession: () =>
set({ accessToken: null, role: null, isAuthenticated: false }),
}));

A refresh-token-aware fetcher

This is the piece that makes TanStack Query and your auth flow cooperate: a single apiFetch wrapper that transparently refreshes the access token on a 401, retries the original request once, and only logs the user out if the refresh itself fails.

// src/lib/apiFetch.ts
import { useAuthStore } from '../stores/useAuthStore';
let refreshPromise: Promise<string | null> | null = null;
async function refreshAccessToken(): Promise<string | null> {
// dedupe concurrent refresh calls — if 5 queries 401 at once,
// we only hit /auth/refresh once
if (!refreshPromise) {
refreshPromise = fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // refresh token lives in an httpOnly cookie
})
.then(async (res) => {
if (!res.ok) throw new Error('refresh failed');
const { accessToken, role } = await res.json();
useAuthStore.getState().setSession(accessToken, role);
return accessToken as string;
})
.catch(() => {
useAuthStore.getState().clearSession();
return null;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
export async function apiFetch<T>(url: string, init: RequestInit = {}): Promise<T> {
const token = useAuthStore.getState().accessToken;
  const doFetch = (accessToken: string | null) =>
fetch(url, {
...init,
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...init.headers,
},
});
  let res = await doFetch(token);
  if (res.status === 401) {
const newToken = await refreshAccessToken();
if (!newToken) throw new Error('Session expired');
res = await doFetch(newToken); // retry exactly once
}
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
}

Because this lives inside apiFetch, every useQuery and useMutation in the app automatically gets refresh-token handling for free — no per-hook boilerplate.

RBAC-gated UI reading the store

// src/components/AdminPanelLink.tsx
import { useAuthStore } from '../stores/useAuthStore';
export function AdminPanelLink() {
// narrow selector: this component only re-renders when `role` changes
const role = useAuthStore((s) => s.role);
  if (role !== 'admin') return null;
return <a href="/admin">Admin Panel</a>;
}
// src/features/admin/useAdminMetrics.ts — a protected query
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/useAuthStore';
import { apiFetch } from '../../lib/apiFetch';
export function useAdminMetrics() {
const role = useAuthStore((s) => s.role);
  return useQuery({
queryKey: ['admin', 'metrics'],
queryFn: () => apiFetch('/api/admin/metrics'),
enabled: role === 'admin', // don't even fire the request for non-admins
});
}

The enabled flag is the glue: TanStack Query reads role state from Zustand and decides whether to fetch at all, so a member user never triggers a 403 round-trip in the first place.

Migration path from a Context-heavy app

If you’ve got a large Context provider tree doing double duty as a server-state cache, migrate incrementally — don’t rewrite everything in one PR.

Step 1: Move fetched data out first. Anything in Context that came from fetch/axios and has a loading/error flag next to it is a useQuery candidate. Replace the provider's useEffect fetch with a hook, keep the consuming components' prop shape the same for now.

// before: inside a Context provider
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch('/api/orders').then(r => r.json()).then(setOrders).finally(() => setLoading(false));
}, []);
// after: delete that block, replace the value with:
const { data: orders = [], isLoading: loading } = useOrders();

This alone usually removes the largest chunk of re-render churn, because Context stops re-broadcasting on every fetch cycle.

Step 2: Move frequently-changing UI flags to Zustand. Sidebar state, modal open/closed, wizard steps, filters — anything currently in Context that changes on clicks/keystrokes and causes sibling re-renders. Create a store, migrate one flag at a time, delete the corresponding useState + Context field.

Step 3: Shrink Context down to truly static values — theme, locale, injected clients, and (as shown above) the resolved user object once it’s populated by your Zustand-backed auth flow. At this point Context is doing what it’s actually good at, and your provider tree should be noticeably shorter.

Step 4: Add React.memo only where profiling says you still need it. After steps 1–3, most re-render problems disappear on their own because the value that used to trigger them no longer lives in Context.

Decision matrix: which tool for which kind of state

State type Example Tool Why Data fetched from an API Orders, user profile, product catalog TanStack Query Caching, dedup, background refetch, and invalidation are built in Frequently-changing global UI state Sidebar open, active theme, wizard step Zustand Selector-based subscriptions avoid broad re-renders Auth session identity isAuthenticated, role, accessToken Zustand Global, read everywhere, changes infrequently but needs fine-grained subscriptions Rarely-changing, broadly-needed static value Locale, design tokens, injected SDK client Context No external library needed; value is stable so re-renders aren't a concern Form input state Text fields, checkboxes in one form Local useState Scoped to the component tree that owns the form; no need to globalize it Derived/computed data from a query Filtered/sorted order list useMemo off useQuery data Don't duplicate server state into another store — derive it Optimistic UI updates on mutation Marking an order shipped instantly TanStack Query mutations onMutate/onError/onSettled give you rollback for free Cross-tab/cross-window sync Logout syncing across tabs Zustand + storage event listener, or Context only within one tab Zustand stores can be persisted/synced; plain Context can't cross tab boundaries

If you remember one thing from this table: the query for “what tool should manage this state” should really be “where did this value come from, and how often does it change.” Server origin → TanStack Query. Frequent client changes with broad consumers → Zustand. Stable and broadly needed → Context. Everything else → local useState.

Wrapping Up

You don’t need to pick one winner — you need to stop treating “state management” as a single problem. In the app we just built, TanStack Query owns anything that touched the network, Zustand owns auth identity and UI flags that change on every click, and Context is left holding exactly what it’s good at: stable, broadly-shared values.

If you’re starting from a Context-heavy codebase, don’t do a big-bang rewrite. Pull out one useEffect-based fetch into a useQuery hook this week, ship it, watch your re-render count in React DevTools Profiler, and repeat. You'll likely find — like I did — that most of what felt like "we need Redux" was actually "we need a cache," and TanStack Query was the answer the whole time.

Next step I’d actually try: open your existing auth Context provider, count how many components consume it just to read user.role, and migrate that one value into a Zustand store this week. Measure the re-render count before and after with the Profiler tab — the difference is usually the fastest performance win you'll ship all quarter.

Author card for Chirag Mehta, Full-Stack Developer specializing in React, Next.js and Node.js. Links: github.com/chiragmehta900, linkedin.com/in/chiragmehta900, medium.com/@chiragmehta900.
Chirag Mehta

Originally published on Medium

Clap, comment or follow along there

Read on Medium →