React State Management in 2026: Zustand vs Redux vs Jotai vs React Query
Compare React state management libraries in 2026 — Zustand, Redux Toolkit, Jotai, and React Query. Includes real code patterns, when to use each, and the common
React State Management in 2026: Zustand vs Redux vs Jotai vs React Query
The React ecosystem's approach to state management has shifted dramatically over the past three years. Redux, once the default for any React application beyond a toy project, is now often overkill. React Query has absorbed much of what people used Redux for (server state). Zustand handles client state in a fraction of the boilerplate.
This guide maps the current landscape — what each library does well, what it doesn't, and how to decide which combination is right for your application.
The State Categories That Actually Matter
Before comparing libraries, get clear on what type of state you're managing:
| State Type | Definition | Right Tool |
|---|---|---|
| Server state | Data fetched from an API — asynchronous, cached, possibly stale | React Query / SWR |
| Client state | UI state that lives only in the browser — modals, selected items, form drafts | Zustand / Jotai / useState |
| URL state | State that belongs in the URL — filters, pagination, search terms | Next.js router / nuqs |
| Form state | Validation, dirty fields, submission state | React Hook Form |
| Component state | Local to a single component, not shared | useState / useReducer |
The most common mistake: using Redux (or any global store) for server state. Developers store API responses in Redux, then write selectors, reducers, and thunks to manage loading states — all of which React Query handles automatically with caching, refetching, and stale-time management.
React Query: For Server State
React Query (now TanStack Query) is the right choice for any state that comes from an API. It manages caching, background refetching, loading/error states, pagination, infinite scroll, and optimistic updates.
// lib/api.ts — typed fetcher
async function fetchUser(userId: string): Promise<User> {
const res = await fetch(`/api/users/${userId}`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// hooks/useUser.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function useUser(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // Data fresh for 5 minutes
gcTime: 10 * 60 * 1000, // Keep in cache 10 minutes after unmount
retry: 2,
enabled: !!userId, // Only fetch if userId is defined
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { userId: string; updates: Partial<User> }) =>
fetch(`/api/users/${data.userId}`, {
method: 'PATCH',
body: JSON.stringify(data.updates),
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()),
// Optimistic update — update cache before server responds
onMutate: async ({ userId, updates }) => {
await queryClient.cancelQueries({ queryKey: ['users', userId] });
const previous = queryClient.getQueryData<User>(['users', userId]);
queryClient.setQueryData<User>(['users', userId], old =>
old ? { ...old, ...updates } : old
);
return { previous }; // Snapshot for rollback
},
onError: (err, { userId }, context) => {
// Roll back optimistic update on error
if (context?.previous) {
queryClient.setQueryData(['users', userId], context.previous);
}
},
onSettled: (_, __, { userId }) => {
// Always refetch after mutation settles
queryClient.invalidateQueries({ queryKey: ['users', userId] });
},
});
}
// Component usage — clean, no boilerplate
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useUser(userId);
const updateUser = useUpdateUser();
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
<h1>{user.name}</h1>
<button
onClick={() => updateUser.mutate({ userId, updates: { role: 'admin' } })}
disabled={updateUser.isPending}
>
{updateUser.isPending ? 'Saving…' : 'Make Admin'}
</button>
</div>
);
}
Infinite scroll with React Query:
export function useProductsFeed(categoryId: string) {
return useInfiniteQuery({
queryKey: ['products', 'feed', categoryId],
queryFn: ({ pageParam }) =>
fetch(`/api/products?category=${categoryId}&cursor=${pageParam}&limit=20`)
.then(r => r.json()),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? null,
});
}
function ProductFeed({ categoryId }: { categoryId: string }) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useProductsFeed(categoryId);
return (
<>
{data?.pages.flatMap(page => page.items).map(product => (
<ProductCard key={product.id} product={product} />
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
)}
</>
);
}
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Zustand: For Client State
Zustand is the minimal client-state library. No context, no reducers, no boilerplate — just a store with actions.
// stores/useCartStore.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
total: () => number;
itemCount: () => number;
}
export const useCartStore = create<CartStore>()(
devtools(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find(i => i.id === item.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === item.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter(i => i.id !== id) })),
updateQuantity: (id, quantity) =>
set((state) => ({
items: quantity === 0
? state.items.filter(i => i.id !== id)
: state.items.map(i => i.id === id ? { ...i, quantity } : i),
})),
clearCart: () => set({ items: [] }),
// Derived state as functions (not stored — computed on access)
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
itemCount: () =>
get().items.reduce((sum, item) => sum + item.quantity, 0),
}),
{
name: 'cart-storage', // localStorage key
partialize: (state) => ({ items: state.items }), // Only persist items
}
),
{ name: 'CartStore' } // DevTools name
)
);
// Component usage — subscribe to only what you need
function CartIcon() {
const itemCount = useCartStore(state => state.itemCount());
return <Badge count={itemCount} />;
}
function CartItem({ id }: { id: string }) {
const item = useCartStore(state => state.items.find(i => i.id === id));
const updateQuantity = useCartStore(state => state.updateQuantity);
const removeItem = useCartStore(state => state.removeItem);
// Only re-renders when THIS item changes
}
UI state store (modal, drawer, notifications):
// stores/useUIStore.ts
import { create } from 'zustand';
interface Notification {
id: string;
type: 'success' | 'error' | 'info';
message: string;
}
interface UIStore {
// Modal state
activeModal: string | null;
modalProps: Record<string, unknown>;
openModal: (modal: string, props?: Record<string, unknown>) => void;
closeModal: () => void;
// Sidebar
sidebarOpen: boolean;
toggleSidebar: () => void;
// Notifications (toast)
notifications: Notification[];
addNotification: (notification: Omit<Notification, 'id'>) => void;
removeNotification: (id: string) => void;
}
export const useUIStore = create<UIStore>((set) => ({
activeModal: null,
modalProps: {},
openModal: (modal, props = {}) => set({ activeModal: modal, modalProps: props }),
closeModal: () => set({ activeModal: null, modalProps: {} }),
sidebarOpen: false,
toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen })),
notifications: [],
addNotification: (notification) =>
set(state => ({
notifications: [
...state.notifications,
{ ...notification, id: crypto.randomUUID() },
],
})),
removeNotification: (id) =>
set(state => ({
notifications: state.notifications.filter(n => n.id !== id),
})),
}));
Jotai: For Atomic State
Jotai uses atomic state — each piece of state is an independent atom. Components subscribe to exactly what they need. Good for fine-grained reactivity where many small pieces of state are independently updated.
// atoms/themeAtoms.ts
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
// Persisted atom — syncs with localStorage
export const themeAtom = atomWithStorage<'light' | 'dark' | 'system'>(
'theme',
'system'
);
// Derived atom — computed from other atoms
export const resolvedThemeAtom = atom((get) => {
const theme = get(themeAtom);
if (theme !== 'system') return theme;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
// Write-only atom with side effects
export const toggleThemeAtom = atom(null, (get, set) => {
const current = get(themeAtom);
set(themeAtom, current === 'dark' ? 'light' : 'dark');
});
// Usage
function ThemeToggle() {
const theme = useAtomValue(themeAtom);
const toggle = useSetAtom(toggleThemeAtom);
return <button onClick={toggle}>{theme === 'dark' ? '☀️' : '🌙'}</button>;
}
// Async atoms — for async derived state
const userIdAtom = atom<string | null>(null);
const userDataAtom = atom(async (get) => {
const userId = get(userIdAtom);
if (!userId) return null;
const res = await fetch(`/api/users/${userId}`);
return res.json();
});
// Note: prefer React Query for server state over async atoms
🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Redux Toolkit: When It's Still the Right Choice
Redux Toolkit (RTK) is the modern Redux — it eliminates boilerplate with createSlice and createAsyncThunk. But when should you still choose it?
Use Redux Toolkit when:
- Large team with strict patterns (Redux enforces consistent architecture)
- Complex state interactions across many slices (Redux DevTools time-travel)
- Already using RTK and the migration cost isn't justified
- Enterprise requirement for specific tooling
// store/slices/filterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface FilterState {
categories: string[];
priceRange: [number, number];
sortBy: 'price_asc' | 'price_desc' | 'newest' | 'relevance';
inStockOnly: boolean;
}
const initialState: FilterState = {
categories: [],
priceRange: [0, 10000],
sortBy: 'relevance',
inStockOnly: false,
};
export const filterSlice = createSlice({
name: 'filters',
initialState,
reducers: {
setCategories: (state, action: PayloadAction<string[]>) => {
state.categories = action.payload;
},
toggleCategory: (state, action: PayloadAction<string>) => {
const idx = state.categories.indexOf(action.payload);
if (idx >= 0) state.categories.splice(idx, 1);
else state.categories.push(action.payload);
},
setPriceRange: (state, action: PayloadAction<[number, number]>) => {
state.priceRange = action.payload;
},
setSortBy: (state, action: PayloadAction<FilterState['sortBy']>) => {
state.sortBy = action.payload;
},
resetFilters: () => initialState,
},
});
Don't use Redux for: API responses (use React Query), simple UI toggles (use useState), anything that doesn't genuinely need to be global.
Decision Guide
Is this data fetched from an API?
└── YES → React Query (not Redux, not Zustand)
Is this state shared across multiple unrelated components?
├── YES, simple (cart, UI state, auth) → Zustand
├── YES, fine-grained atoms (theme, feature flags) → Jotai
└── YES, large team, strict patterns → Redux Toolkit
Is this state local to one component or its children?
└── YES → useState / useReducer (no library needed)
Is this state that should be in the URL (filters, pagination)?
└── YES → nuqs or Next.js router searchParams
The modern React stack (2026):
- Server state: React Query
- Client global state: Zustand
- URL state: nuqs
- Form state: React Hook Form
- Component state: useState
This covers 95% of applications without Redux.
Working With Viprasol
We build React frontends with modern state management patterns — React Query for data fetching, Zustand for client state, React Hook Form for forms. We also help teams migrate from legacy Redux setups that have become unmaintainable.
→ Talk to our frontend team about your React architecture.
See Also
- Next.js Performance — React Server Components reduce client state needs
- TypeScript Advanced Patterns — type-safe state management
- Web Performance Optimization — state management performance impact
- Software Testing Strategies — testing components with state
- Web Development Services — React frontend development
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.