Frontend Architecture
Part 1 of 11State Management at Scale — Beyond Redux, a Pragmatic Guide
State Management at Scale — Beyond Redux, a Pragmatic Guide
Introduction
Every React application that grows past a certain size hits the state management wall. Components need data from distant siblings. User actions ripple across the UI. Cache invalidation becomes a nightmare. And somewhere along the way, someone suggests Redux.
Redux isn't bad. For years, it was the answer. But the landscape has changed dramatically. We now understand that "state" isn't monolithic—it's several distinct categories, each with different characteristics and optimal solutions. We have React Query for server state, Zustand for simple global state, Jotai for atomic state, and yes, Redux Toolkit for complex client state.
The challenge isn't picking a library. It's understanding what kind of state you're dealing with, and matching each type to the right tool.
This guide cuts through the noise. We'll categorize state properly, evaluate modern solutions honestly, and give you a framework for making state management decisions at scale—without over-engineering or under-building.
The State Management Landscape
Why State Management Is Hard
THE FUNDAMENTAL TENSIONS:
════════════════════════════════════════════════════════════════════
┌─────────────────────────────────────────────────────────────────┐
│ │
│ LOCALITY vs SHARING │
│ ─────────────────── │
│ State should live close to where it's used (locality) │
│ But multiple components need the same state (sharing) │
│ │
│ Component A ──┐ │
│ Component B ──┼── All need user data │
│ Component C ──┘ │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SINGLE SOURCE OF TRUTH vs DERIVED STATE │
│ ─────────────────────────────────────── │
│ One canonical source prevents inconsistency │
│ But computing derived state on every render is expensive │
│ │
│ Source: cart items │
│ Derived: cart total, item count, tax... recompute every time? │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CONSISTENCY vs PERFORMANCE │
│ ────────────────────────── │
│ All components should see the same state │
│ But updating everything on every change is slow │
│ │
│ User updates profile name... │
│ → Re-render header? Sidebar? Every comment? Settings page? │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SIMPLICITY vs PREDICTABILITY │
│ ──────────────────────────── │
│ Direct mutations are simple (object.value = x) │
│ But immutable updates with actions are predictable/debuggable │
│ │
│ Simple: user.name = 'New Name' │
│ Predictable: dispatch({ type: 'UPDATE_NAME', name: 'New' }) │
│ │
└─────────────────────────────────────────────────────────────────┘
The State Taxonomy
NOT ALL STATE IS THE SAME:
════════════════════════════════════════════════════════════════════
┌─────────────────────────────────────────────────────────────────┐
│ │
│ SERVER STATE (Remote State) │
│ ─────────────────────────── │
│ Data that lives on the server, cached on the client. │
│ │
│ Characteristics: │
│ • You don't own it (server is source of truth) │
│ • Can become stale │
│ • Requires fetching, caching, revalidation │
│ • Often shared across many components │
│ │
│ Examples: │
│ • User profile from API │
│ • Product catalog │
│ • Comments, posts, messages │
│ │
│ Best tools: React Query, SWR, Apollo Client, RTK Query │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CLIENT STATE (Application State) │
│ ──────────────────────────────── │
│ Data that only exists in the browser, owned by the client. │
│ │
│ Characteristics: │
│ • You own it completely │
│ • Synchronous updates │
│ • May need to persist (localStorage) │
│ • Often derived from user actions │
│ │
│ Examples: │
│ • Shopping cart (before checkout) │
│ • Multi-step form data │
│ • User preferences (theme, language) │
│ • Filter/sort selections │
│ │
│ Best tools: Zustand, Redux Toolkit, Jotai, Context │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ UI STATE (Ephemeral State) │
│ ────────────────────────── │
│ Transient state for UI interactions. Doesn't persist. │
│ │
│ Characteristics: │
│ • Short-lived │
│ • Usually local to a component or small subtree │
│ • Lost on navigation/refresh (and that's fine) │
│ • High-frequency updates │
│ │
│ Examples: │
│ • Modal open/closed │
│ • Dropdown expanded │
│ • Input focus state │
│ • Hover states │
│ • Loading indicators │
│ │
│ Best tools: useState, useReducer, local component state │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ URL STATE (Navigation State) │
│ ──────────────────────────── │
│ State encoded in the URL. Shareable and bookmarkable. │
│ │
│ Characteristics: │
│ • Survives refresh │
│ • Shareable via link │
│ • Browser back/forward works │
│ • Limited data types (strings) │
│ │
│ Examples: │
│ • Search query (?q=shoes) │
│ • Pagination (?page=3) │
│ • Filters (?color=red&size=large) │
│ • Sort order (?sort=price_asc) │
│ • Selected tab (/settings/security) │
│ │
│ Best tools: URL params, useSearchParams, router state │
│ │
└─────────────────────────────────────────────────────────────────┘
THE MISTAKE: Treating all state the same and putting
everything in one global store.
THE FIX: Use the right tool for each state category.
Server State: The Biggest Win
Why Server State Is Special
SERVER STATE IS NOT LIKE OTHER STATE:
════════════════════════════════════════════════════════════════════
Traditional state management assumes:
• State is synchronous
• You control the data
• Updates are immediate
• Single source of truth is your store
Server state reality:
• State is asynchronous (network latency)
• Server controls the data
• Updates might fail
• Server is the source of truth, you have a cache
┌─────────────────────────────────────────────────────────────────┐
│ │
│ CLIENT SERVER │
│ ────── ────── │
│ ┌────────────┐ ┌────────────┐ │
│ │ Cache │ ◄── Fetch ────── │ Truth │ │
│ │ (stale?) │ │ │ │
│ └────────────┘ └────────────┘ │
│ │ │ │
│ │ Is this still valid? │ Another user │
│ │ When to refetch? │ just changed this │
│ │ What if fetch fails? │ │
│ ▼ ▼ │
│ │
│ Server state management is CACHE management. │
│ │
└─────────────────────────────────────────────────────────────────┘
WHAT YOU ACTUALLY NEED FOR SERVER STATE:
────────────────────────────────────────
• Caching (don't refetch what you have)
• Background refetching (keep data fresh)
• Stale-while-revalidate (show stale, fetch fresh)
• Request deduplication (don't fetch same thing twice)
• Automatic retries (handle transient failures)
• Cache invalidation (clear when data changes)
• Optimistic updates (assume success, rollback on failure)
• Pagination/infinite scroll support
• Loading/error states
Building this yourself = months of work.
React Query/SWR = already solved.
React Query (TanStack Query)
// THE BASICS:
// ═══════════════════════════════════════════════════════════════
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetching data
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
});
if (isLoading) return <Spinner />;
if (error) return <Error error={error} />;
return <div>{data.name}</div>;
}
// That's it. You get:
// ✓ Caching (same userId = same cache entry)
// ✓ Deduplication (10 components mounting = 1 fetch)
// ✓ Background refetch (when tab focuses, component mounts)
// ✓ Loading/error states
// ✓ Automatic garbage collection
// MUTATIONS (Creating/Updating/Deleting):
// ═══════════════════════════════════════════════════════════════
function UpdateProfileForm({ user }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newData) => updateUser(user.id, newData),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['user', user.id] });
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
mutation.mutate({ name: e.target.name.value });
}}>
<input name="name" defaultValue={user.name} />
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : 'Save'}
</button>
{mutation.isError && <span>Error: {mutation.error.message}</span>}
</form>
);
}
// OPTIMISTIC UPDATES:
// ═══════════════════════════════════════════════════════════════
const mutation = useMutation({
mutationFn: updateUser,
// When mutation starts
onMutate: async (newData) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['user', userId] });
// Snapshot previous value
const previousUser = queryClient.getQueryData(['user', userId]);
// Optimistically update
queryClient.setQueryData(['user', userId], (old) => ({
...old,
...newData,
}));
// Return context for rollback
return { previousUser };
},
// If mutation fails, rollback
onError: (err, newData, context) => {
queryClient.setQueryData(['user', userId], context.previousUser);
},
// Always refetch after success or error
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
Query Key Design
// QUERY KEYS ARE YOUR CACHE STRUCTURE:
// ═══════════════════════════════════════════════════════════════
// Hierarchical keys enable smart invalidation
// All users
queryKey: ['users']
// Specific user
queryKey: ['users', userId]
// User's posts
queryKey: ['users', userId, 'posts']
// Specific post
queryKey: ['users', userId, 'posts', postId]
// Posts with filters
queryKey: ['posts', { status: 'published', author: userId }]
// INVALIDATION PATTERNS:
// ═══════════════════════════════════════════════════════════════
// Invalidate everything about a user
queryClient.invalidateQueries({ queryKey: ['users', userId] });
// Clears: ['users', userId], ['users', userId, 'posts'], etc.
// Invalidate just the user list
queryClient.invalidateQueries({ queryKey: ['users'], exact: true });
// Invalidate all posts (across all users)
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'posts'
});
// KEY FACTORY PATTERN (Recommended for large apps):
// ═══════════════════════════════════════════════════════════════
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
posts: (id: string) => [...userKeys.detail(id), 'posts'] as const,
};
// Usage
useQuery({ queryKey: userKeys.detail(userId), ... });
useQuery({ queryKey: userKeys.posts(userId), ... });
// Invalidation
queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });
queryClient.invalidateQueries({ queryKey: userKeys.all }); // Everything
When to Use React Query vs Other Solutions
REACT QUERY IS FOR:
════════════════════════════════════════════════════════════════════
✓ Any data that comes from an API
✓ Data that can become stale
✓ Data shared across components
✓ Paginated/infinite lists
✓ Data that needs background refresh
React Query is NOT for:
✗ Client-only state (use Zustand, Context)
✗ Form state (use React Hook Form)
✗ UI state (use useState)
✗ URL state (use router)
ALTERNATIVES COMPARISON:
════════════════════════════════════════════════════════════════════
│ React Query │ SWR │ RTK Query │ Apollo
────────────────────┼─────────────┼──────────┼───────────┼─────────
Bundle size │ ~13KB │ ~4KB │ ~12KB* │ ~33KB
DevTools │ Excellent │ Basic │ Excellent │ Good
Mutations │ ✓ │ ✓ │ ✓ │ ✓
Optimistic updates │ ✓ │ Manual │ ✓ │ ✓
Infinite queries │ ✓ │ ✓ │ ✓ │ ✓
SSR support │ ✓ │ ✓ │ ✓ │ ✓
Normalized cache │ ✗ │ ✗ │ ✗ │ ✓
GraphQL specific │ ✗ │ ✗ │ ✗ │ ✓
Redux integration │ ✗ │ ✗ │ ✓ │ ✗
* Plus Redux
RECOMMENDATIONS:
────────────────
• REST API, no Redux: React Query or SWR
• REST API, already using Redux: RTK Query
• GraphQL: Apollo Client or React Query
• Simple needs, bundle size priority: SWR
• Complex needs, best DX: React Query
Client State: What's Actually Left
The Shrinking Domain of Client State
AFTER EXTRACTING SERVER STATE, WHAT'S LEFT?
════════════════════════════════════════════════════════════════════
BEFORE (Everything in Redux):
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Redux Store │
│ ├── user (from API) ─────────────────────┐ │
│ ├── products (from API) ─────────────────┤ │
│ ├── cart │ Server State │
│ ├── orders (from API) ───────────────────┤ (60-80% of store) │
│ ├── notifications (from API) ────────────┘ │
│ ├── theme │
│ ├── sidebarOpen │ Client State │
│ ├── filters │ (20-40% of store) │
│ └── currentModal │ │
│ │
└─────────────────────────────────────────────────────────────────┘
AFTER (Proper separation):
┌─────────────────────────────────────────────────────────────────┐
│ │
│ React Query │ │
│ ├── ['user'] │ Server State │
│ ├── ['products', filters] │ Handles caching, │
│ ├── ['orders'] │ refetching, etc. │
│ └── ['notifications'] │ │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ Zustand Store │ │
│ ├── cart (persisted) │ Client State │
│ └── theme (persisted) │ Simpler! │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ URL State │ │
│ └── filters (?category=shoes&sort=price) │ Shareable │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ Component State │ │
│ ├── sidebarOpen (useState in Layout) │ UI State │
│ └── currentModal (useState in App) │ Local │
│ │
└─────────────────────────────────────────────────────────────────┘
What's left for "global client state management"? Not much!
Zustand: The Simple Choice
// ZUSTAND BASICS:
// ═══════════════════════════════════════════════════════════════
import { create } from 'zustand';
// Define store
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
totalItems: () => number;
}
const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => ({
items: [...state.items, item],
})),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
clearCart: () => set({ items: [] }),
// Derived state as method
totalItems: () => get().items.reduce((sum, item) => sum + item.quantity, 0),
}));
// Use anywhere (no Provider needed!)
function CartIcon() {
const totalItems = useCartStore((state) => state.totalItems());
return <span>Cart ({totalItems})</span>;
}
function CartPage() {
const items = useCartStore((state) => state.items);
const removeItem = useCartStore((state) => state.removeItem);
return items.map((item) => (
<div key={item.id}>
{item.name}
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
));
}
function ProductCard({ product }) {
const addItem = useCartStore((state) => state.addItem);
return (
<button onClick={() => addItem({ ...product, quantity: 1 })}>
Add to Cart
</button>
);
}
Zustand Patterns for Scale
// SLICES PATTERN (Organize large stores):
// ═══════════════════════════════════════════════════════════════
// stores/cartSlice.ts
export interface CartSlice {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
}
export const createCartSlice = (set, get): CartSlice => ({
items: [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] })),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
});
// stores/userSlice.ts
export interface UserSlice {
preferences: UserPreferences;
setTheme: (theme: Theme) => void;
setLanguage: (lang: string) => void;
}
export const createUserSlice = (set): UserSlice => ({
preferences: { theme: 'light', language: 'en' },
setTheme: (theme) =>
set((state) => ({
preferences: { ...state.preferences, theme },
})),
setLanguage: (language) =>
set((state) => ({
preferences: { ...state.preferences, language },
})),
});
// stores/index.ts
import { create } from 'zustand';
import { createCartSlice, CartSlice } from './cartSlice';
import { createUserSlice, UserSlice } from './userSlice';
type StoreState = CartSlice & UserSlice;
export const useStore = create<StoreState>()((...args) => ({
...createCartSlice(...args),
...createUserSlice(...args),
}));
// PERSISTENCE:
// ═══════════════════════════════════════════════════════════════
import { persist, createJSONStorage } from 'zustand/middleware';
const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
// ...
}),
{
name: 'cart-storage', // localStorage key
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ items: state.items }), // Only persist items
}
)
);
// DEVTOOLS:
// ═══════════════════════════════════════════════════════════════
import { devtools } from 'zustand/middleware';
const useStore = create<StoreState>()(
devtools(
persist(
(set, get) => ({
// ... store definition
}),
{ name: 'app-storage' }
),
{ name: 'AppStore' } // Name in Redux DevTools
)
);
// SELECTORS (Prevent unnecessary re-renders):
// ═══════════════════════════════════════════════════════════════
// Bad: Re-renders on ANY store change
function Component() {
const store = useStore(); // Subscribes to entire store
return <div>{store.items.length}</div>;
}
// Good: Only re-renders when items change
function Component() {
const items = useStore((state) => state.items);
return <div>{items.length}</div>;
}
// Better: Only re-renders when items.length changes
import { shallow } from 'zustand/shallow';
function Component() {
const itemCount = useStore((state) => state.items.length);
return <div>{itemCount}</div>;
}
// Multiple values with shallow comparison
function Component() {
const { items, addItem } = useStore(
(state) => ({ items: state.items, addItem: state.addItem }),
shallow
);
}
Jotai: Atomic State
// JOTAI FOR FINE-GRAINED REACTIVITY:
// ═══════════════════════════════════════════════════════════════
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// Primitive atoms
const countAtom = atom(0);
const textAtom = atom('hello');
// Derived atoms (computed)
const doubleCountAtom = atom((get) => get(countAtom) * 2);
// Writable derived atoms
const uppercaseTextAtom = atom(
(get) => get(textAtom).toUpperCase(),
(get, set, newValue: string) => set(textAtom, newValue.toLowerCase())
);
// Usage
function Counter() {
const [count, setCount] = useAtom(countAtom);
const doubleCount = useAtomValue(doubleCountAtom);
return (
<div>
<span>{count} × 2 = {doubleCount}</span>
<button onClick={() => setCount((c) => c + 1)}>+</button>
</div>
);
}
// ATOMS WITH ASYNC:
// ═══════════════════════════════════════════════════════════════
const userIdAtom = atom<string | null>(null);
// Async derived atom
const userAtom = atom(async (get) => {
const id = get(userIdAtom);
if (!id) return null;
const response = await fetch(`/api/users/${id}`);
return response.json();
});
// Usage with Suspense
function UserProfile() {
const user = useAtomValue(userAtom); // Suspends while loading
return <div>{user?.name}</div>;
}
// WHEN JOTAI SHINES:
// ═══════════════════════════════════════════════════════════════
// Complex derived state across many atoms
const cartItemsAtom = atom<CartItem[]>([]);
const discountCodeAtom = atom<string>('');
const shippingMethodAtom = atom<ShippingMethod>('standard');
// Each derived atom only recalculates when its dependencies change
const subtotalAtom = atom((get) =>
get(cartItemsAtom).reduce((sum, item) => sum + item.price * item.qty, 0)
);
const discountAtom = atom((get) => {
const code = get(discountCodeAtom);
const subtotal = get(subtotalAtom);
return calculateDiscount(code, subtotal);
});
const shippingCostAtom = atom((get) => {
const method = get(shippingMethodAtom);
const subtotal = get(subtotalAtom);
return calculateShipping(method, subtotal);
});
const totalAtom = atom((get) =>
get(subtotalAtom) - get(discountAtom) + get(shippingCostAtom)
);
// Only components using totalAtom re-render when total changes
// Components using subtotalAtom don't re-render when shipping changes
When to Use What
CLIENT STATE LIBRARY DECISION:
════════════════════════════════════════════════════════════════════
Simple Complex
Global Global
State State
│ │
▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ │
│ ZUSTAND │
│ ─────── │
│ • Simple API (create + use) │
│ • No Provider needed │
│ • Good DevTools support │
│ • Easy persistence │
│ • ~1.5KB bundle │
│ │
│ Best for: Most apps. Shopping cart, user preferences, │
│ UI state that spans components. │
│ │
├──────────────────────────────────────────────────────────────────┤
│ │
│ JOTAI │
│ ───── │
│ • Atomic model (like Recoil, but simpler) │
│ • Fine-grained reactivity │
│ • Great for derived state │
│ • Suspense-first async │
│ • ~2.5KB bundle │
│ │
│ Best for: Complex derived state, many interdependent values, │
│ when you need surgical re-render control. │
│ │
├──────────────────────────────────────────────────────────────────┤
│ │
│ REDUX TOOLKIT │
│ ───────────── │
│ • Mature ecosystem │
│ • Time-travel debugging │
│ • RTK Query for server state │
│ • Middleware support │
│ • ~11KB bundle (+ react-redux) │
│ │
│ Best for: Large teams needing strict patterns, apps already │
│ using Redux, complex action orchestration. │
│ │
├──────────────────────────────────────────────────────────────────┤
│ │
│ REACT CONTEXT │
│ ───────────── │
│ • Built-in (no dependency) │
│ • Simple for small state │
│ • Re-renders all consumers on change │
│ • 0KB bundle │
│ │
│ Best for: Theme, locale, auth status—infrequently changing │
│ values consumed by many components. │
│ │
│ NOT for: Frequently changing state, performance-sensitive apps │
│ │
└──────────────────────────────────────────────────────────────────┘
QUICK DECISION:
───────────────
• Just need simple global state? → Zustand
• Complex derived/computed state? → Jotai
• Already have Redux? → Stay with Redux Toolkit
• Very simple needs (theme, auth)? → Context
• Want everything integrated? → Redux Toolkit + RTK Query
Redux: When It Still Makes Sense
Redux Isn't Dead
REDUX HAS EVOLVED:
════════════════════════════════════════════════════════════════════
OLD REDUX (Why people hated it):
────────────────────────────────
• Tons of boilerplate
• Action types, action creators, reducers in separate files
• Immutable updates were painful
• No standard async solution
• Had to add lots of packages
// action-types.js
export const ADD_TODO = 'ADD_TODO';
// actions.js
export const addTodo = (text) => ({
type: ADD_TODO,
payload: { text, id: nanoid() },
});
// reducer.js
export default function todosReducer(state = [], action) {
switch (action.type) {
case ADD_TODO:
return [...state, action.payload];
default:
return state;
}
}
// Repeat for every feature...
MODERN REDUX (Redux Toolkit):
─────────────────────────────
• Minimal boilerplate
• Immer built-in (write "mutable" code)
• createSlice does everything
• RTK Query for server state
• Great TypeScript support
// features/todos/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: [] as Todo[],
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
// "Mutate" directly - Immer handles immutability
state.push({ id: nanoid(), text: action.payload, done: false });
},
toggleTodo: (state, action: PayloadAction<string>) => {
const todo = state.find((t) => t.id === action.payload);
if (todo) todo.done = !todo.done;
},
removeTodo: (state, action: PayloadAction<string>) => {
return state.filter((t) => t.id !== action.payload);
},
},
});
export const { addTodo, toggleTodo, removeTodo } = todosSlice.actions;
export default todosSlice.reducer;
// Done. Actions and reducer in one place.
When Redux Is the Right Choice
REDUX MAKES SENSE WHEN:
════════════════════════════════════════════════════════════════════
1. LARGE TEAM, STRICT PATTERNS NEEDED
───────────────────────────────────
Redux enforces a consistent pattern.
New developers know where to look.
Code reviews have clear standards.
Team of 3: Zustand is fine
Team of 30: Redux patterns help
2. COMPLEX ACTION ORCHESTRATION
─────────────────────────────
Multiple actions need to coordinate.
Middleware can intercept and transform.
// Thunk for complex async flow
export const checkout = createAsyncThunk(
'cart/checkout',
async (_, { getState, dispatch }) => {
const { cart, user } = getState();
// Validate
if (!cart.items.length) throw new Error('Empty cart');
// Create order
const order = await api.createOrder(cart.items, user.id);
// Clear cart
dispatch(cartSlice.actions.clear());
// Add to order history
dispatch(ordersSlice.actions.add(order));
// Show notification
dispatch(notificationsSlice.actions.show({
type: 'success',
message: 'Order placed!'
}));
return order;
}
);
3. TIME-TRAVEL DEBUGGING NEEDED
─────────────────────────────
Redux DevTools let you:
• See every action that fired
• Jump to any point in state history
• Export/import state for bug reports
• Replay user sessions
Zustand has DevTools too, but Redux's are more mature.
4. ALREADY USING REDUX
────────────────────
Migration cost often exceeds benefit.
Just modernize to Redux Toolkit.
5. WANT ONE INTEGRATED SOLUTION
─────────────────────────────
Redux Toolkit + RTK Query = client + server state in one.
Shared caching, integrated DevTools, one mental model.
RTK Query: Redux for Server State
// RTK QUERY - SERVER STATE THE REDUX WAY:
// ═══════════════════════════════════════════════════════════════
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User', 'Post'],
endpoints: (builder) => ({
// Queries (GET)
getUser: builder.query<User, string>({
query: (id) => `users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
getPosts: builder.query<Post[], void>({
query: () => 'posts',
providesTags: (result) =>
result
? [...result.map(({ id }) => ({ type: 'Post' as const, id })), 'Post']
: ['Post'],
}),
// Mutations (POST/PUT/DELETE)
updateUser: builder.mutation<User, Partial<User> & { id: string }>({
query: ({ id, ...patch }) => ({
url: `users/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
}),
addPost: builder.mutation<Post, Partial<Post>>({
query: (body) => ({
url: 'posts',
method: 'POST',
body,
}),
invalidatesTags: ['Post'],
}),
}),
});
export const {
useGetUserQuery,
useGetPostsQuery,
useUpdateUserMutation,
useAddPostMutation,
} = api;
// Usage in components
function UserProfile({ id }: { id: string }) {
const { data: user, isLoading, error } = useGetUserQuery(id);
const [updateUser, { isLoading: isUpdating }] = useUpdateUserMutation();
if (isLoading) return <Spinner />;
if (error) return <Error error={error} />;
return (
<div>
<h1>{user.name}</h1>
<button
onClick={() => updateUser({ id, name: 'New Name' })}
disabled={isUpdating}
>
Update Name
</button>
</div>
);
}
// Store setup
import { configureStore } from '@reduxjs/toolkit';
import { api } from './api';
import cartReducer from './features/cart/cartSlice';
export const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
cart: cartReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
});
Architecture Patterns
The Colocation Principle
STATE SHOULD LIVE CLOSE TO WHERE IT'S USED:
════════════════════════════════════════════════════════════════════
START LOCAL, LIFT AS NEEDED:
────────────────────────────
Level 1: Component State
─────────────────────────
State used by one component.
function SearchInput() {
const [query, setQuery] = useState(''); // Local
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
Level 2: Lifted State
─────────────────────
State shared by siblings. Lift to common parent.
function SearchPage() {
const [query, setQuery] = useState(''); // Lifted
return (
<>
<SearchInput value={query} onChange={setQuery} />
<SearchResults query={query} />
</>
);
}
Level 3: Context
────────────────
State needed by many components in a subtree.
const SearchContext = createContext();
function SearchProvider({ children }) {
const [query, setQuery] = useState('');
return (
<SearchContext.Provider value={{ query, setQuery }}>
{children}
</SearchContext.Provider>
);
}
Level 4: Global Store
─────────────────────
State needed across the entire app, across routes.
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
}));
DON'T:
──────
• Start with global state "just in case"
• Put everything in one store
• Use global state for local UI interactions
DO:
───
• Start with useState
• Lift only when you have to
• Use global stores for truly global state
Feature-Based Organization
ORGANIZE BY FEATURE, NOT BY TYPE:
════════════════════════════════════════════════════════════════════
BAD (organized by type):
────────────────────────
src/
├── actions/
│ ├── cartActions.ts
│ ├── userActions.ts
│ └── productActions.ts
├── reducers/
│ ├── cartReducer.ts
│ ├── userReducer.ts
│ └── productReducer.ts
├── selectors/
│ ├── cartSelectors.ts
│ ├── userSelectors.ts
│ └── productSelectors.ts
├── components/
│ ├── Cart.tsx
│ ├── UserProfile.tsx
│ └── ProductList.tsx
└── ...
To work on cart: touch 5+ files in different directories
GOOD (organized by feature):
────────────────────────────
src/
├── features/
│ ├── cart/
│ │ ├── CartPage.tsx
│ │ ├── CartItem.tsx
│ │ ├── cartStore.ts # or cartSlice.ts
│ │ ├── cartApi.ts # React Query hooks
│ │ ├── cart.types.ts
│ │ └── index.ts # Public exports
│ │
│ ├── user/
│ │ ├── UserProfile.tsx
│ │ ├── UserSettings.tsx
│ │ ├── userStore.ts
│ │ ├── userApi.ts
│ │ └── index.ts
│ │
│ └── products/
│ ├── ProductList.tsx
│ ├── ProductDetail.tsx
│ ├── productsApi.ts
│ └── index.ts
│
├── shared/
│ ├── components/
│ ├── hooks/
│ └── utils/
│
└── app/
├── store.ts # Combine all feature stores
├── queryClient.ts # React Query setup
└── App.tsx
To work on cart: everything is in features/cart/
The API Layer Pattern
// SEPARATE DATA FETCHING FROM COMPONENTS:
// ═══════════════════════════════════════════════════════════════
// features/products/productsApi.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Keys
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
};
// Fetchers (pure functions, testable)
async function fetchProducts(filters: ProductFilters): Promise<Product[]> {
const params = new URLSearchParams(filters as Record<string, string>);
const response = await fetch(`/api/products?${params}`);
if (!response.ok) throw new Error('Failed to fetch products');
return response.json();
}
async function fetchProduct(id: string): Promise<Product> {
const response = await fetch(`/api/products/${id}`);
if (!response.ok) throw new Error('Failed to fetch product');
return response.json();
}
// Hooks (what components use)
export function useProducts(filters: ProductFilters) {
return useQuery({
queryKey: productKeys.list(filters),
queryFn: () => fetchProducts(filters),
});
}
export function useProduct(id: string) {
return useQuery({
queryKey: productKeys.detail(id),
queryFn: () => fetchProduct(id),
enabled: !!id,
});
}
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Product> }) =>
fetch(`/api/products/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}).then((r) => r.json()),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: productKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
},
});
}
// features/products/ProductList.tsx
import { useProducts } from './productsApi';
export function ProductList({ filters }) {
const { data: products, isLoading, error } = useProducts(filters);
// Component just renders, doesn't know about fetch details
if (isLoading) return <Spinner />;
if (error) return <Error error={error} />;
return (
<div>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Migration Strategies
From Redux to Modern Stack
// INCREMENTAL MIGRATION: Redux → React Query + Zustand
// ═══════════════════════════════════════════════════════════════
// PHASE 1: Add React Query alongside Redux
// ─────────────────────────────────────────
// Keep Redux for now, add React Query for new features
// app/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Provider } from 'react-redux';
import { store } from './store';
const queryClient = new QueryClient();
export function Providers({ children }) {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</Provider>
);
}
// PHASE 2: Migrate server state feature by feature
// ────────────────────────────────────────────────
// BEFORE: Redux async thunk
// features/products/productsSlice.ts (OLD)
export const fetchProducts = createAsyncThunk(
'products/fetch',
async (filters) => {
const response = await fetch(`/api/products?${filters}`);
return response.json();
}
);
const productsSlice = createSlice({
name: 'products',
initialState: { items: [], loading: false, error: null },
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => {
state.loading = true;
})
.addCase(fetchProducts.fulfilled, (state, action) => {
state.items = action.payload;
state.loading = false;
})
.addCase(fetchProducts.rejected, (state, action) => {
state.error = action.error.message;
state.loading = false;
});
},
});
// AFTER: React Query
// features/products/productsApi.ts (NEW)
export function useProducts(filters) {
return useQuery({
queryKey: ['products', filters],
queryFn: () => fetch(`/api/products?${filters}`).then(r => r.json()),
});
}
// Component migration
// BEFORE
function ProductList() {
const dispatch = useDispatch();
const { items, loading, error } = useSelector(state => state.products);
useEffect(() => {
dispatch(fetchProducts(filters));
}, [filters]);
// ...
}
// AFTER
function ProductList() {
const { data: items, isLoading, error } = useProducts(filters);
// ... same render logic, less code
}
// PHASE 3: Migrate client state to Zustand
// ────────────────────────────────────────
// BEFORE: Redux slice for cart
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
addItem: (state, action) => { state.items.push(action.payload); },
removeItem: (state, action) => {
state.items = state.items.filter(i => i.id !== action.payload);
},
},
});
// AFTER: Zustand store
const useCartStore = create(
persist(
(set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
removeItem: (id) => set((s) => ({
items: s.items.filter((i) => i.id !== id)
})),
}),
{ name: 'cart' }
)
);
// PHASE 4: Remove Redux when migration complete
// ─────────────────────────────────────────────
// Uninstall: redux, react-redux, @reduxjs/toolkit
// Remove: store.ts, Provider wrapper
// Done!
From Prop Drilling to Global State
// RECOGNIZING WHEN TO EXTRACT STATE:
// ═══════════════════════════════════════════════════════════════
// BEFORE: Prop drilling through 4 levels
function App() {
const [user, setUser] = useState(null);
return (
<Layout user={user}>
<Sidebar user={user} />
<Main user={user} setUser={setUser}>
<Dashboard user={user}>
<UserWidget user={user} setUser={setUser} />
</Dashboard>
</Main>
</Layout>
);
}
// Problems:
// • Every component in the chain needs user props
// • Adding new user data means updating many components
// • Components that don't use user still pass it through
// AFTER: Global store
const useUserStore = create((set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
}));
function App() {
return (
<Layout>
<Sidebar />
<Main>
<Dashboard>
<UserWidget />
</Dashboard>
</Main>
</Layout>
);
}
// Components that need user just grab it
function UserWidget() {
const user = useUserStore((s) => s.user);
const logout = useUserStore((s) => s.logout);
return (
<div>
<span>{user?.name}</span>
<button onClick={logout}>Logout</button>
</div>
);
}
function Sidebar() {
// Doesn't need user? Doesn't import it.
return <nav>...</nav>;
}
Anti-Patterns and Best Practices
Anti-Patterns
STATE MANAGEMENT ANTI-PATTERNS:
════════════════════════════════════════════════════════════════════
1. PUTTING EVERYTHING IN GLOBAL STATE
───────────────────────────────────
Problem: Modal open state in Redux
// Why is this global?
dispatch(openModal('confirmDelete'));
Impact: Unnecessary complexity, hard to reason about
Fix: Local state for local concerns
const [isOpen, setIsOpen] = useState(false);
2. DUPLICATING SERVER STATE
─────────────────────────
Problem: Copying API data into Redux
const userSlice = createSlice({
name: 'user',
reducers: {
setUser: (state, action) => {
state.user = action.payload; // Copied from API
},
},
});
Impact: Stale data, manual cache management, bugs
Fix: Use React Query—it IS the cache
const { data: user } = useQuery({ queryKey: ['user'] });
3. NOT SELECTING PROPERLY
───────────────────────
Problem: Subscribing to entire store
// Every store change re-renders this component
const store = useStore();
return <div>{store.user.name}</div>;
Fix: Select only what you need
const name = useStore((s) => s.user.name);
4. OVER-NORMALIZING
─────────────────
Problem: Normalizing simple data
// Do you really need this?
{
entities: {
users: { 1: {...}, 2: {...} },
posts: { 1: {...}, 2: {...} },
},
ids: {
users: [1, 2],
posts: [1, 2],
},
}
Impact: Complex selectors, hard to understand
Fix: Normalize only when you have real duplication issues
5. MIXING CONCERNS IN ONE STORE
─────────────────────────────
Problem: Server state + client state + UI state
const store = {
// Server state (should be React Query)
users: [],
posts: [],
isLoadingUsers: false,
usersError: null,
// Client state (fine in store)
cart: [],
preferences: {},
// UI state (should be local)
isModalOpen: false,
activeTab: 'posts',
};
Fix: Separate by category
6. ACTIONS THAT DO TOO MUCH
─────────────────────────
Problem: God actions
dispatch(initializeApp());
// Fetches user, preferences, cart, notifications,
// sets up websocket, initializes analytics...
Impact: Untestable, unpredictable, hard to debug
Fix: Single responsibility actions, orchestrate at component level
Best Practices
STATE MANAGEMENT BEST PRACTICES:
════════════════════════════════════════════════════════════════════
1. CATEGORIZE YOUR STATE FIRST
────────────────────────────
Before coding, ask:
• Is this from the server? → React Query
• Is this client-only, app-wide? → Zustand/Redux
• Is this URL-representable? → URL state
• Is this UI-specific? → useState
2. START SIMPLE, SCALE UP
───────────────────────
Don't reach for global state first.
Journey: useState → lift state → context → global store
Most state never needs to go past "lift state."
3. COLOCATE STATE WITH UI
───────────────────────
// State and UI together
features/
└── cart/
├── CartPage.tsx
├── cartStore.ts # State
└── cartApi.ts # Server state
Not:
stores/cartStore.ts # Separate from UI
components/CartPage.tsx
4. USE SELECTORS CONSISTENTLY
───────────────────────────
// Define selectors in store file
const useCartStore = create(...);
// Derived selectors
export const useCartItems = () =>
useCartStore((s) => s.items);
export const useCartTotal = () =>
useCartStore((s) =>
s.items.reduce((sum, i) => sum + i.price * i.qty, 0)
);
// Components use selectors
function CartSummary() {
const total = useCartTotal();
return <span>${total}</span>;
}
5. PERSIST INTENTIONALLY
──────────────────────
Not everything should survive refresh.
Persist:
• Shopping cart
• User preferences
• Draft content
Don't persist:
• Server state (React Query handles this)
• UI state (modals, tabs)
• Derived state
6. TYPE YOUR STATE
────────────────
interface CartState {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
}
const useCartStore = create<CartState>()(...);
TypeScript catches bugs before runtime.
7. TEST STATE LOGIC SEPARATELY
────────────────────────────
// cartStore.test.ts
import { useCartStore } from './cartStore';
beforeEach(() => {
useCartStore.setState({ items: [] });
});
test('addItem adds to cart', () => {
useCartStore.getState().addItem({ id: '1', name: 'Test', price: 10 });
expect(useCartStore.getState().items).toHaveLength(1);
});
// No component rendering needed!
Quick Reference
┌─────────────────────────────────────────────────────────────────────┐
│ STATE MANAGEMENT QUICK REFERENCE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ STATE CATEGORIES │
│ ───────────────────────────────────────────────────────────────── │
│ Server State │ Data from API │ React Query, SWR, RTK Query │
│ Client State │ App data, no server │ Zustand, Redux, Jotai │
│ UI State │ Ephemeral, local │ useState, useReducer │
│ URL State │ Shareable, bookmark │ searchParams, router │
│ │
│ QUICK LIBRARY SELECTION │
│ ───────────────────────────────────────────────────────────────── │
│ "Just need simple global state" → Zustand │
│ "Have lots of derived/computed state" → Jotai │
│ "Large team, need strict patterns" → Redux Toolkit │
│ "Already using Redux" → Stay, use RTK │
│ "Theme/locale (rarely changes)" → Context │
│ "Data from API" → React Query │
│ │
│ ZUSTAND CHEATSHEET │
│ ───────────────────────────────────────────────────────────────── │
│ const useStore = create((set, get) => ({ │
│ count: 0, │
│ inc: () => set((s) => ({ count: s.count + 1 })), │
│ get: () => get().count, │
│ })); │
│ │
│ // Use with selector │
│ const count = useStore((s) => s.count); │
│ │
│ REACT QUERY CHEATSHEET │
│ ───────────────────────────────────────────────────────────────── │
│ // Query │
│ const { data, isLoading } = useQuery({ │
│ queryKey: ['user', id], │
│ queryFn: () => fetchUser(id), │
│ }); │
│ │
│ // Mutation │
│ const mutation = useMutation({ │
│ mutationFn: updateUser, │
│ onSuccess: () => queryClient.invalidateQueries(['user']), │
│ }); │
│ │
│ STATE COLOCATION LADDER │
│ ───────────────────────────────────────────────────────────────── │
│ 1. useState (one component) │
│ 2. Lift to parent (siblings share) │
│ 3. Context (subtree shares) │
│ 4. Global store (app-wide) │
│ │
│ Start at 1. Only climb when necessary. │
│ │
│ RED FLAGS │
│ ───────────────────────────────────────────────────────────────── │
│ ✗ "Let's put it in Redux just in case" │
│ ✗ Modal isOpen in global store │
│ ✗ Copying API response into Redux │
│ ✗ Subscribing to entire store (no selector) │
│ ✗ Everything in one giant store │
│ │
│ BUNDLE SIZES │
│ ───────────────────────────────────────────────────────────────── │
│ Zustand │ ~1.5KB │
│ Jotai │ ~2.5KB │
│ React Query │ ~13KB │
│ Redux Toolkit │ ~11KB (+ react-redux ~5KB) │
│ MobX │ ~17KB │
│ │
└─────────────────────────────────────────────────────────────────────┘
Conclusion
State management at scale isn't about finding one solution that handles everything. It's about recognizing that "state" is multiple distinct problems, each with its own optimal solution.
The modern approach:
-
Server state → React Query (or SWR, RTK Query)
- This is the biggest win. Stop manually managing loading states, caching, and refetching.
-
Client state → Zustand (or Jotai, Redux Toolkit)
- After extracting server state, what's left is usually simple.
- Pick based on complexity: Zustand for most apps, Jotai for complex derived state, Redux for large teams.
-
UI state → useState
- Keep it local. Not everything needs to be global.
-
URL state → URL
- Filters, pagination, search queries—put them in the URL.
The key insights:
- Most of what we put in Redux was server state pretending to be client state
- After using React Query, your Redux store (or replacement) shrinks dramatically
- The best state management is the least state management
- Start local, lift only when necessary
Redux isn't dead—Redux Toolkit is excellent for complex client state and large teams. But it's no longer the default answer. The answer now is: "What kind of state is this?" and pick the right tool for that category.
The goal isn't to use the most sophisticated state management. It's to keep your app simple enough that state is easy to reason about, while providing the features your users need. Sometimes that's a single Zustand store. Sometimes that's Redux with RTK Query. The right answer depends on your team, your app, and your specific requirements.
What did you think?