Advanced State Management in React: Choosing the Right Tool for the Job
A practical guide to Redux Toolkit, Zustand, Jotai, TanStack Query, and React Context — with real-world architecture patterns and performance techniques
State management sits at the heart of every non-trivial React application. Get it right and your codebase scales gracefully; get it wrong and you end up with prop-drilling nightmares, stale UI, and components re-rendering themselves into oblivion. The good news is that the React ecosystem has matured dramatically. You now have a rich menu of purpose-built tools — Redux Toolkit, Zustand, Jotai, TanStack Query, and the built-in Context API — each optimised for a specific class of problem.
This article walks through each solution in depth, explains the trade-offs with concrete examples, and gives you a decision framework you can apply immediately to your own projects.
Understanding the Different Kinds of State
Before reaching for any library, it is worth being precise about what kind of state you are actually managing. Conflating different state categories is the root cause of most over-engineered React applications.
- Local UI state — whether a dropdown is open, which tab is active, the current value of a controlled input. This state is owned by a single component or a small subtree.
- Shared client state — data that multiple, potentially distant components need to read or write: the currently authenticated user, theme preferences, a shopping cart.
- Server state — data that originates on a server, is asynchronous by nature, and needs cache invalidation, background re-fetching, and loading/error lifecycle management.
- URL state — filters, pagination, and search terms that should survive a page refresh and be shareable via a link.
- Form state — controlled inputs, validation errors, and submission status, often best handled by a dedicated library such as React Hook Form.
The single most impactful thing you can do is resist the urge to pour every category of state into one global store. Each category has different consistency requirements, different lifetimes, and different update frequencies.
React Context: The Built-In Option
The Context API ships with React and requires no additional dependencies. It works well for state that changes infrequently and is consumed by many components — classic examples are theme, locale, and the authenticated user object.
// AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface AuthState {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = async (credentials: Credentials) => {
const user = await authService.login(credentials);
setUser(user);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
return ctx;
}
The Context Performance Trap
Context has a well-known performance characteristic that trips up many developers: every consumer re-renders whenever the context value reference changes. If you store a large, frequently-mutated object in a single context, you will trigger unnecessary re-renders across your whole tree.
The mitigation strategies are:
- Split contexts by update frequency. Keep the user object in one context and the UI preferences in another.
- Memoize the value object with
useMemoso the reference only changes when the data actually changes. - Use
React.memoon consumer components to skip re-renders when the props they care about have not changed.
The honest conclusion is that Context is excellent for low-frequency global state, but it is not a general-purpose state management solution. Once you find yourself writing elaborate memoization to work around Context's re-render behaviour, it is time to reach for a dedicated library.
Redux Toolkit: The Mature Enterprise Choice
Redux has a reputation for boilerplate — a reputation it earned in the pre-Toolkit era. Redux Toolkit (RTK) eliminates the ceremony while preserving what makes Redux powerful: a single, predictable, inspectable state tree with strict unidirectional data flow.
RTK ships createSlice, which generates action creators and reducers from a single definition, and uses Immer under the hood so you can write mutations that look imperative but are actually applied immutably.
// features/cart/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
name: string;
quantity: number;
price: number;
}
interface CartState {
items: CartItem[];
coupon: string | null;
}
const initialState: CartState = { items: [], coupon: null };
export const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem(state, action: PayloadAction<CartItem>) {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
existing.quantity += action.payload.quantity;
} else {
state.items.push(action.payload);
}
},
removeItem(state, action: PayloadAction<string>) {
state.items = state.items.filter(i => i.id !== action.payload);
},
applyCoupon(state, action: PayloadAction<string>) {
state.coupon = action.payload;
},
},
});
export const { addItem, removeItem, applyCoupon } = cartSlice.actions;
export default cartSlice.reducer;
RTK Query: Server State Inside Redux
RTK ships a companion called RTK Query that handles server state directly within the Redux store. It auto-generates React hooks from endpoint definitions and manages caching, invalidation, and optimistic updates.
// services/productsApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const productsApi = createApi({
reducerPath: 'productsApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Product'],
endpoints: (builder) => ({
getProducts: builder.query<Product[], void>({
query: () => '/products',
providesTags: ['Product'],
}),
updateProduct: builder.mutation<Product, Partial<Product>>({
query: ({ id, ...patch }) => ({
url: `/products/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: ['Product'],
}),
}),
});
export const { useGetProductsQuery, useUpdateProductMutation } = productsApi;
Redux Toolkit is the right choice when you need time-travel debugging via Redux DevTools, when multiple teams need to contribute to a shared state model with enforced conventions, or when you have complex cross-slice business logic that benefits from middleware such as redux-saga or redux-observable.
Zustand: Lightweight Global State Without the Ceremony
Zustand takes a radically different approach. There is no provider, no reducer, and no action type. You define a store as a plain JavaScript object with state and methods, then consume it with a single hook. The entire API fits on one screen.
// stores/useCartStore.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
totalPrice: () => number;
clear: () => void;
}
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 + item.quantity }
: i
),
};
}
return { items: [...state.items, item] };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
totalPrice: () =>
get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
clear: () => set({ items: [] }),
}),
{ name: 'cart-storage' }
)
)
);
Granular Subscriptions and Performance
Zustand solves Context's re-render problem through selector-based subscriptions. A component only re-renders when the slice of state it selected has changed.
// Only re-renders when items.length changes, not on price changes
const itemCount = useCartStore((state) => state.items.length);
// Only re-renders when the total changes
const total = useCartStore((state) => state.totalPrice());
Zustand's middleware ecosystem covers persistence to localStorage, DevTools integration, Immer-style mutations, and URL sync. It is the go-to choice for teams that want Redux-level capability without Redux-level setup cost.
Jotai: Atomic State Inspired by Recoil
Jotai models state as a graph of atoms — tiny, composable units of state that can be derived from one another. This model is particularly powerful for dynamic state: think a list of editor nodes where each node has its own independent selection/focus state, or a complex form where field visibility depends on other field values.
// atoms/filterAtoms.ts
import { atom, selector } from 'jotai';
export const searchTermAtom = atom('');
export const categoryAtom = atom<string | null>(null);
export const productsAtom = atom<Product[]>([]);
// Derived atom — recomputes only when its dependencies change
export const filteredProductsAtom = atom((get) => {
const products = get(productsAtom);
const term = get(searchTermAtom).toLowerCase();
const category = get(categoryAtom);
return products.filter((p) => {
const matchesTerm = p.name.toLowerCase().includes(term);
const matchesCategory = category === null || p.category === category;
return matchesTerm && matchesCategory;
});
});
// ProductList.tsx
import { useAtom, useAtomValue } from 'jotai';
function ProductList() {
const [searchTerm, setSearchTerm] = useAtom(searchTermAtom);
const filtered = useAtomValue(filteredProductsAtom);
return (
<>
<input value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
{filtered.map((p) => <ProductCard key={p.id} product={p} />)}
</>
);
}
Jotai's fine-grained reactivity model means that when the search term changes, only components that actually subscribe to filteredProductsAtom re-render. Components subscribed only to categoryAtom are untouched. This makes Jotai excellent for applications with dense, interdependent state graphs.
TanStack Query: The Right Home for Server State
TanStack Query (formerly React Query) is not a general state management library — it is a server-state library, and it handles that responsibility better than anything else in the ecosystem. Fetching, caching, synchronising, and updating remote data in React without TanStack Query typically means manually managing loading flags, error objects, and cache invalidation in either local state or a global store. TanStack Query automates all of that.
// hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function useProducts(filters: ProductFilters) {
return useQuery({
queryKey: ['products', filters],
queryFn: () => api.getProducts(filters),
staleTime: 5 * 60 * 1000, // treat data as fresh for 5 minutes
gcTime: 10 * 60 * 1000, // keep unused data in cache for 10 minutes
});
}
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (update: ProductUpdate) => api.updateProduct(update),
// Optimistic update
onMutate: async (update) => {
await queryClient.cancelQueries({ queryKey: ['products'] });
const previous = queryClient.getQueryData(['products']);
queryClient.setQueryData(['products'], (old: Product[]) =>
old.map((p) => (p.id === update.id ? { ...p, ...update } : p))
);
return { previous };
},
onError: (_err, _update, context) => {
queryClient.setQueryData(['products'], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
The query key array is TanStack Query's caching primitive. Any query sharing the same key shares the same cache entry. When you invalidate a key, every component subscribed to that key automatically re-fetches in the background and updates when new data arrives.
Prefetching and Hydration in Next.js
In a Next.js application you can prefetch queries on the server and dehydrate them into the HTML payload, eliminating loading spinners for the initial page render entirely.
// app/products/page.tsx (Next.js App Router)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function ProductsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['products', {}],
queryFn: () => api.getProducts({}),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductList />
</HydrationBoundary>
);
}
Combining Libraries: A Practical Architecture
Real applications almost always benefit from combining multiple tools, each responsible for its own category of state. A well-structured mid-to-large React application might look like this:
- TanStack Query owns all server state. It fetches, caches, and synchronises remote data. Nothing else touches API responses.
- Zustand owns global client state — the shopping cart, the authenticated user session, UI preferences, and any cross-feature coordination that is too complex for Context.
- React Context owns low-churn configuration — the current theme, the locale, feature flags.
- useState / useReducer own local component state — modal open/closed, form field values (or React Hook Form for complex forms), tab selection.
- URL search params own navigational state — active filters, sort order, current page number.
This separation of concerns is not just aesthetic. It means your server-state cache never gets polluted with UI state, your global client store never balloons with response data that TanStack Query would cache more efficiently, and your Context never triggers app-wide re-renders because someone stored a rapidly-changing value in it.
Performance Patterns Worth Knowing
Memoised Selectors with Reselect
When deriving computed values from a Redux store, use Reselect to avoid recomputing on every render. RTK re-exports createSelector from Reselect.
import { createSelector } from '@reduxjs/toolkit';
const selectItems = (state: RootState) => state.cart.items;
export const selectCartSummary = createSelector(selectItems, (items) => ({
count: items.reduce((n, i) => n + i.quantity, 0),
total: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}));
Splitting Renders with useTransition
React 18's useTransition lets you mark a state update as non-urgent so the browser can interrupt it to handle higher-priority work like user input. This is particularly useful when a state change triggers an expensive re-render.
const [isPending, startTransition] = useTransition();
const handleFilterChange = (value: string) => {
startTransition(() => {
setFilterValue(value); // expensive downstream re-render is deferrable
});
};
Avoiding Object Identity Issues
A common source of phantom re-renders is creating new object or array literals inside render. useMemo and useCallback are your tools here, but apply them only when you have evidence of a performance problem — premature memoization adds cognitive overhead without guaranteed benefit.
Decision Framework
Use the following decision tree when choosing a state management approach for a new feature:
- Is the state purely local to one component or a small subtree? Use
useStateoruseReducer. - Does the state represent data fetched from a server? Use TanStack Query.
- Is it global client state that many components across the tree need? Reach for Zustand unless you are already in a Redux codebase.
- Does the state change rarely and serve a configuration purpose (theme, locale)? Use React Context.
- Is the state highly interconnected with many derived values? Consider Jotai.
- Do you need time-travel debugging, strict architectural conventions, or complex async sagas? Use Redux Toolkit.
Common Anti-Patterns to Avoid
- Storing server responses in Redux or Zustand. Let TanStack Query own that data. Duplicating it in a global store creates synchronisation bugs.
- Colocating everything in a single massive store. Unrelated state belonging in the same slice forces unnecessary coupling and makes testing harder.
- Using Context for high-frequency updates. Every context value change re-renders all consumers. For values that change on every keystroke or animation frame, Context is the wrong tool.
- Deriving state inside components. Computed values that depend on state should be memoised selectors or derived atoms, not inline calculations that run on every render.
- Ignoring loading and error states. Server state is inherently asynchronous. TanStack Query surfaces
isLoading,isError, anderrorfor every query; use them.
Conclusion
The state management landscape in React has never been healthier. You no longer need to choose between a heavy framework and building everything from scratch. Redux Toolkit delivers enterprise-grade predictability without the boilerplate of its predecessor. Zustand brings that same power with a fraction of the setup. Jotai solves the hard problems in fine-grained, dynamic state graphs. And TanStack Query has definitively solved server state — if you are still managing API responses in useEffect and useState, you owe it to yourself to migrate.
The most important insight is categorical: identify what kind of state you are dealing with first, then select the tool optimised for that category. Resist the instinct to funnel everything into one system. A layered approach — TanStack Query for server state, Zustand for global client state, Context for configuration, and local state for everything else — produces codebases that are easier to reason about, easier to test, and far more maintainable as teams and requirements grow.