ποΈ Lesson 8.1: State Management Overview
Welcome to Module 8! As your React applications grow, managing state becomes increasingly challenging. What starts as simple useState calls can evolve into prop drilling nightmares, performance bottlenecks, and maintenance headaches. In this lesson, you'll learn to recognize different types of state, understand when to use various state management solutions, and make informed architectural decisions that scale with your application.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Distinguish between local state, shared state, server state, and URL state
- Identify when to keep state local vs. when to lift it up
- Understand the trade-offs between different state management solutions
- Recognize the signs that your application needs a state management library
- Compare popular state management libraries and their use cases
- Apply architectural patterns for organizing application state
- Select the right state management tool for your specific needs
- Plan a scalable state management strategy from the beginning
Estimated Time: 60-75 minutes
Prerequisites: Modules 1-7, especially Module 5 (Advanced Hooks and Patterns)
π In This Lesson
π€ What is State Management?
Before diving into different solutions, let's establish what we mean by "state" and why managing it effectively is crucial to building maintainable React applications.
Defining State
π Definition
State is any data that changes over time in your application. It represents the current "snapshot" of your application at any given momentβwhat the user sees, what data has been loaded, what the user has selected, and so on.
Think of state as your application's memory. Just as you remember what you had for breakfast or what tabs you have open in your browser, your application needs to "remember" things like:
- Is the user logged in?
- What items are in the shopping cart?
- Which theme is active (light or dark)?
- Is a modal currently open?
- What data did we fetch from the API?
- What text is the user typing in the search box?
Why State Management Matters
As applications grow, managing state becomes one of the most challenging aspects of frontend development. Here's why:
The Evolution of an Application's State
Let's look at how state management evolves as an application grows:
| Application Size | State Complexity | Typical Solution | Example |
|---|---|---|---|
| Small (1-5 components) |
Simple, contained | Local useState | Counter, toggle button |
| Medium (5-20 components) |
Shared between siblings | Lifting state up, props | Todo list, simple form |
| Large (20-50 components) |
Many distant components need access | Context API, custom hooks | E-commerce cart, user auth |
| Very Large (50+ components) |
Complex interdependencies | State management library (Zustand, Redux) | Admin dashboard, social media app |
π‘ Key Insight
State management isn't about choosing a fancy libraryβit's about making data flow in your application predictable, debuggable, and maintainable. The best state management solution is often the simplest one that solves your specific problems.
State Management Goals
Good state management should achieve these goals:
- Predictability: Given the same state and actions, the application behaves the same way
- Maintainability: Easy to understand, modify, and extend
- Debuggability: Easy to trace state changes and identify bugs
- Performance: Efficient updates without unnecessary re-renders
- Developer Experience: Pleasant to work with, good TypeScript support
- Testability: Easy to test state logic in isolation
β The Golden Rule
Start simple, scale when needed. Don't reach for Redux on day one if useState and props work fine. But also don't hesitate to refactor when you feel pain points. The best architecture evolves with your application's needs.
π― Types of State in React Applications
Not all state is created equal. Understanding the different categories of state helps you choose the right management approach for each type.
The Four Categories of State
1. Local State (UI State)
π Definition
Local state is data that only a single component (and possibly its children) needs to know about. It's ephemeral, lives only as long as the component is mounted, and doesn't need to be shared globally.
Examples of local state:
- Form input values (before submission)
- Is a dropdown menu open?
- Current tab in a tabbed interface
- Is a tooltip visible?
- Hover state of a button
Best managed with: useState, useReducer
// Example: Local state for a dropdown
function Dropdown({ options }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState<string | null>(null);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
{selected || 'Select an option'}
</button>
{isOpen && (
<ul>
{options.map(option => (
<li
key={option.value}
onClick={() => {
setSelected(option.label);
setIsOpen(false);
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}
β When to Use Local State
Use local state when: Only one component needs it, it doesn't persist when unmounted, and it doesn't need to be synchronized with other parts of the app.
2. Shared State (Application State)
π Definition
Shared state is data that multiple components across your application need access to. It typically persists longer than a single component's lifecycle and needs to stay synchronized.
Examples of shared state:
- User authentication status
- Current theme (light/dark mode)
- Shopping cart items
- Notification system
- Active language/locale
- Sidebar collapsed/expanded state
Best managed with: Context API, Zustand, Redux, Jotai
// Example: Shared state with Context
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Used anywhere in the app
function Header() {
const { theme, toggleTheme } = useContext(ThemeContext)!;
return (
<header className={theme}>
<button onClick={toggleTheme}>
{theme === 'light' ? 'π' : 'βοΈ'}
</button>
</header>
);
}
β When to Use Shared State
Use shared state when: Multiple components in different parts of the tree need the same data, the data needs to persist across route changes, or you're experiencing "prop drilling" pain.
3. Server State (Remote State)
π Definition
Server state is data that originates from a remote server and is cached on the client. It's unique because you don't fully control itβthe server is the source of truth, and your local copy might be stale.
Examples of server state:
- User profile data fetched from API
- List of products in an e-commerce store
- Blog posts and articles
- Real-time data (stock prices, chat messages)
- Search results
Unique characteristics of server state:
- Asynchronous by nature
- Can become stale and need refreshing
- Multiple components may request the same data
- Needs error handling and loading states
- May need caching and invalidation strategies
- Can have race conditions
Best managed with: React Query (TanStack Query), SWR, RTK Query, Apollo Client (for GraphQL)
// Example: Server state with React Query
import { useQuery } from '@tanstack/react-query';
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data, isLoading, error } = useQuery<User>({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
},
staleTime: 5 * 60 * 1000, // Data is fresh for 5 minutes
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
β οΈ Common Mistake
Don't treat server state the same as client state! Putting API data in Redux/Zustand forces you to manually handle loading states, caching, refetching, and cache invalidationβall things that specialized libraries do automatically.
4. URL State
π Definition
URL state is information stored in the URL itselfβincluding the pathname, query parameters, and hash. It's special because it's shareable, bookmarkable, and survives page refreshes.
Examples of URL state:
- Current page/route
- Search query
- Filter and sort parameters
- Selected tab
- Pagination state
- Modal or dialog state (for deep linking)
Best managed with: React Router, Next.js router, browser URLSearchParams API
// Example: URL state with React Router
import { useSearchParams } from 'react-router-dom';
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') || 'all';
const sortBy = searchParams.get('sort') || 'name';
const page = Number(searchParams.get('page')) || 1;
const updateFilters = (newCategory: string) => {
setSearchParams({
category: newCategory,
sort: sortBy,
page: '1' // Reset to first page on filter change
});
};
return (
<div>
<h2>Products - {category}</h2>
<select value={category} onChange={e => updateFilters(e.target.value)}>
<option value="all">All Products</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
{/* Product list here */}
</div>
);
}
β When to Use URL State
Use URL state when: Users should be able to share or bookmark the current view, state should persist through refreshes, or you want browser back/forward buttons to work naturally.
State Type Comparison
| State Type | Lifespan | Scope | Persistence | Best Tool |
|---|---|---|---|---|
| Local | Component mount | Single component | Lost on unmount | useState |
| Shared | App lifecycle | Multiple components | While app is running | Context/Zustand/Redux |
| Server | Configurable cache | Global (cached) | Managed by library | React Query/SWR |
| URL | Until navigation | Global (in URL) | Survives refresh | React Router |
π‘ Pro Tip
Many state management problems can be solved by simply using the right type of state. For example, if you're putting search filters in Redux, consider using URL params insteadβthey're more user-friendly and require zero extra code for persistence!
βοΈ Local vs Global State
One of the most important architectural decisions in React is determining where state should live. Let's explore the principles and patterns for making this decision.
The Principle of Colocation
π Colocation Principle
"State should live as close as possible to where it's used."
This means keeping state local by default and only "lifting it up" when multiple components genuinely need to share it.
Why Colocation Matters
Keeping state local has several benefits:
- Easier to understand: The state and the components that use it are in the same file
- Better performance: State changes only re-render the components that need to update
- Simpler testing: Components with local state are self-contained and easier to test
- Easier refactoring: Moving or removing components doesn't affect distant parts of the app
- Less cognitive load: You don't need to understand the entire app to work on one component
When to Keep State Local
β Keep State Local When
- Only one component (and maybe its children) needs the state
- The state is UI-specific (hover, focus, open/closed)
- The state doesn't need to persist when the component unmounts
- The state isn't needed by sibling components
// Good: Local state for accordion
function Accordion({ items }: { items: Item[] }) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<div>
{items.map((item, index) => (
<div key={item.id}>
<button onClick={() => setOpenIndex(index === openIndex ? null : index)}>
{item.title}
</button>
{openIndex === index && <div>{item.content}</div>}
</div>
))}
</div>
);
}
When to Lift State Up
Sometimes state needs to be "lifted up" to a common ancestor so multiple components can access it:
State Lives Here] --> B[Child A
Reads State] A --> C[Child B
Updates State] A --> D[Child C
Reads State] style A fill:#667eea,color:#fff style B fill:#4CAF50,color:#fff style C fill:#FFA726,color:#fff style D fill:#4CAF50,color:#fff
π‘ Lift State Up When
- Multiple sibling components need the same state
- One component needs to update state that another component displays
- Parent needs to coordinate behavior between children
- State needs to be synchronized across components
// Example: Lifting state up
function SearchPage() {
// State lifted to parent so both children can access it
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
return (
<div>
{/* Child A updates the state */}
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
onSearch={() => fetchResults(searchQuery).then(setResults)}
/>
{/* Child B reads the state */}
<SearchResults
query={searchQuery}
results={results}
/>
</div>
);
}
The Prop Drilling Problem
When you lift state too high or need to pass it through many layers of components, you encounter "prop drilling":
// β Prop drilling problem
function App() {
const [user, setUser] = useState<User | null>(null);
return <Layout user={user} setUser={setUser} />;
}
function Layout({ user, setUser }: LayoutProps) {
return (
<div>
<Sidebar user={user} setUser={setUser} />
<Content user={user} />
</div>
);
}
function Sidebar({ user, setUser }: SidebarProps) {
return (
<nav>
<UserMenu user={user} setUser={setUser} />
</nav>
);
}
function UserMenu({ user, setUser }: UserMenuProps) {
// Finally using it here!
return <div>{user?.name}</div>;
}
Problems with prop drilling:
- Intermediate components need props they don't use (Layout, Sidebar)
- Hard to refactorβchanging prop names affects many files
- Makes component trees rigid and brittle
- TypeScript types become verbose and repetitive
Solutions to Prop Drilling
When prop drilling becomes painful, consider these solutions:
| Solution | When to Use | Complexity |
|---|---|---|
| Component Composition | Layout components, wrapper components | Low |
| Context API | Theme, auth, 2-5 globally shared values | Low-Medium |
| Custom Hooks | Shared logic with local state | Low |
| State Library | Many global values, complex updates | Medium-High |
Component Composition Pattern
Often overlooked, component composition can eliminate prop drilling without any state management library:
// β
Better: Component composition
function App() {
const [user, setUser] = useState<User | null>(null);
return (
<Layout
sidebar={<Sidebar userMenu={<UserMenu user={user} setUser={setUser} />} />}
content={<Content user={user} />}
/>
);
}
function Layout({ sidebar, content }: LayoutProps) {
// No user props needed!
return (
<div>
{sidebar}
{content}
</div>
);
}
function Sidebar({ userMenu }: SidebarProps) {
// No user props needed!
return <nav>{userMenu}</nav>;
}
β Decision Tree
- Start: Keep state in the component that needs it
- If siblings need it: Lift to common parent
- If prop drilling 3+ levels: Try component composition
- If composition doesn't help: Use Context or state library
π¨ Common State Management Problems
As applications grow, certain state management patterns emerge. Recognizing these problems helps you know when it's time to refactor or adopt a different approach.
Problem 1: Prop Drilling Hell
β Symptom
You're passing props through 4+ levels of components that don't use them, just to get data to deeply nested children.
Signs you have this problem:
- Components have props they don't use, only pass through
- Changing a prop name requires editing many files
- Adding a new prop requires touching many components
- Component function signatures are getting long and unwieldy
Solutions:
- Use Context API for truly global data (theme, auth)
- Apply component composition to avoid prop threading
- Consider a state management library if you have many such cases
Problem 2: Scattered State Updates
β Symptom
The same piece of state is being updated in many different places, making it hard to track down bugs.
// β Problem: Cart state updated in many places
function ProductCard({ product }: ProductCardProps) {
const [cart, setCart] = useContext(CartContext)!;
const addToCart = () => {
setCart([...cart, { ...product, quantity: 1 }]);
};
return <button onClick={addToCart}>Add to Cart</button>;
}
function CartButton() {
const [cart, setCart] = useContext(CartContext)!;
const clearCart = () => {
setCart([]);
};
return <button onClick={clearCart}>Clear Cart</button>;
}
// Updates also in CartItem, CheckoutForm, etc...
Solutions:
- Create custom hooks that encapsulate update logic
- Use useReducer to centralize state updates
- Use a state library with action creators
// β
Better: Centralized updates via custom hook
function useCart() {
const [cart, setCart] = useContext(CartContext)!;
const addToCart = (product: Product) => {
setCart(prev => [...prev, { ...product, quantity: 1 }]);
};
const removeFromCart = (productId: string) => {
setCart(prev => prev.filter(item => item.id !== productId));
};
const clearCart = () => {
setCart([]);
};
return { cart, addToCart, removeFromCart, clearCart };
}
// Now components only call the hook methods
function ProductCard({ product }: ProductCardProps) {
const { addToCart } = useCart();
return <button onClick={() => addToCart(product)}>Add</button>;
}
Problem 3: Stale Closures
β Symptom
Event handlers or effects are using old values of state, causing unexpected behavior.
// β Problem: Stale closure
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log('Count:', count); // Always logs 0!
setCount(count + 1); // Always sets to 1!
}, 1000);
return () => clearInterval(interval);
}, []); // Empty deps = stale closure
return <div>{count}</div>;
}
Solutions:
- Use functional updates:
setCount(prev => prev + 1) - Include dependencies in useEffect array
- Use useRef for values you want to reference but not trigger re-renders
// β
Fixed: Functional update
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(prev => {
console.log('Count:', prev);
return prev + 1;
});
}, 1000);
return () => clearInterval(interval);
}, []); // Now safe with empty deps
return <div>{count}</div>;
}
Problem 4: Race Conditions
β Symptom
Async operations complete out of order, causing the wrong data to be displayed (e.g., search results for an old query showing up after newer results).
// β Problem: Race condition
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
fetchResults(query).then(data => {
setResults(data); // Might be stale!
});
}, [query]);
return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}
Solutions:
- Use cleanup function to ignore stale responses
- Use AbortController to cancel old requests
- Use libraries like React Query that handle this automatically
// β
Fixed: Cleanup ignores stale results
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
let ignore = false;
fetchResults(query).then(data => {
if (!ignore) {
setResults(data);
}
});
return () => {
ignore = true; // Cleanup: ignore stale response
};
}, [query]);
return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}
Problem 5: Unnecessary Re-renders
β Symptom
Components re-render even though the data they display hasn't changed, causing performance problems.
Common causes:
- Creating new objects/arrays on every render
- Passing inline functions as props
- Context value changes on every render
- Not memoizing expensive computations
// β Problem: Context re-renders everything
function AppProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState('light');
// New object on every render!
const value = { user, setUser, theme, setTheme };
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
// Every component using this context re-renders when ANY value changes!
Solutions:
- useMemo to memoize context values
- Split contexts by concern (UserContext, ThemeContext)
- Use React.memo for expensive components
- State management libraries with fine-grained reactivity
// β
Better: Separate contexts
function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
// Now changing theme doesn't re-render components that only use user!
π‘ When Problems Indicate You Need a Library
If you're experiencing multiple of these problems simultaneously, and simple refactoring isn't helping, it's probably time to consider a dedicated state management library.
πΊοΈ State Management Solutions Landscape
The React ecosystem offers numerous state management solutions, each designed to solve different problems. Let's explore the landscape and understand where each tool fits.
The State Management Spectrum
useReducer] -.-> A B1[Context API
Custom Hooks] -.-> B C1[Zustand
Jotai
Valtio] -.-> C D1[Redux Toolkit
MobX
XState] -.-> D E1[React Query
SWR
Apollo] -.-> E style A fill:#667eea,color:#fff style E fill:#4CAF50,color:#fff
Category 1: Built-in React Solutions
π¦ What's Included
- useState: Local state management
- useReducer: Complex local state with actions
- useContext: Sharing state without prop drilling
- useRef: Mutable values that don't trigger re-renders
Best for: Small to medium apps, localized state, simple sharing between components
Pros:
- β Zero dependenciesβbuilt into React
- β Simple API, easy to learn
- β Great TypeScript support
- β Perfect for local component state
Cons:
- β Context can cause unnecessary re-renders
- β No built-in devtools
- β Boilerplate increases with app complexity
- β No built-in async handling or middleware
Category 2: Lightweight State Libraries
These libraries provide global state with minimal boilerplate and excellent performance:
Zustand
// Simple, hook-based global state
import create from 'zustand';
interface BearStore {
bears: number;
addBear: () => void;
removeAllBears: () => void;
}
const useStore = create<BearStore>((set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 })
}));
function BearCounter() {
const bears = useStore(state => state.bears);
return <h1>{bears} bears</h1>;
}
Best for: Most React applications, simple to moderate state complexity
Jotai
// Atomic state management
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
{count}
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
Best for: Bottom-up state architecture, derived state, atomic updates
Valtio
// Proxy-based state
import { proxy, useSnapshot } from 'valtio';
const state = proxy({ count: 0 });
function Counter() {
const snap = useSnapshot(state);
return (
<div>
{snap.count}
<button onClick={() => state.count++}>+1</button>
</div>
);
}
Best for: Developers who prefer mutable syntax, quick prototyping
| Library | Bundle Size | Learning Curve | Best Feature |
|---|---|---|---|
| Zustand | ~1KB | Very Low | Simplicity + power |
| Jotai | ~3KB | Low | Atomic architecture |
| Valtio | ~3KB | Very Low | Mutable syntax |
Category 3: Full-Featured State Libraries
Redux Toolkit (RTK)
The modern, official way to write Redux code:
// Redux Toolkit slice
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 } as CounterState,
reducers: {
increment: (state) => {
state.value += 1; // Immer allows "mutation"
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
}
}
});
export const { increment, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
Best for: Large enterprise apps, teams familiar with Redux, complex state logic
Pros:
- β Mature ecosystem with lots of resources
- β Excellent DevTools (Redux DevTools)
- β Predictable state updates
- β Great for time-travel debugging
- β RTK Query for server state
Cons:
- β Steeper learning curve
- β More boilerplate than alternatives
- β Larger bundle size (~20KB)
MobX
Observable-based reactive state management:
// MobX store
import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';
class TodoStore {
todos: string[] = [];
constructor() {
makeAutoObservable(this);
}
addTodo(todo: string) {
this.todos.push(todo);
}
}
const todoStore = new TodoStore();
const TodoList = observer(() => {
return (
<ul>
{todoStore.todos.map((todo, i) => (
<li key={i}>{todo}</li>
))}
</ul>
);
});
Best for: Complex domain models, developers familiar with OOP patterns
XState
State machine-based state management:
// XState machine
import { createMachine } from 'xstate';
import { useMachine } from '@xstate/react';
const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: {
on: { TOGGLE: 'active' }
},
active: {
on: { TOGGLE: 'inactive' }
}
}
});
function Toggle() {
const [state, send] = useMachine(toggleMachine);
return (
<button onClick={() => send('TOGGLE')}>
{state.value === 'active' ? 'ON' : 'OFF'}
</button>
);
}
Best for: Complex workflows, multi-step processes, state machines
Category 4: Server State Libraries
π The Game Changer
Server state libraries revolutionized React development by recognizing that server data is fundamentally different from client state and should be managed differently.
React Query (TanStack Query)
// React Query hooks
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: number }) {
// Automatic loading, error, caching, refetching
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: (updates: Partial<User>) =>
fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(updates)
}),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries(['user', userId]);
}
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error!</div>;
return <div>{data.name}</div>;
}
Features:
- β Automatic caching and background refetching
- β Request deduplication
- β Optimistic updates
- β Pagination and infinite scroll
- β Built-in loading and error states
- β DevTools for debugging
SWR (Stale-While-Revalidate)
// SWR - Similar to React Query, Vercel's solution
import useSWR from 'swr';
function Profile() {
const { data, error, isLoading } = useSWR('/api/user', fetcher);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Failed to load</div>;
return <div>Hello {data.name}!</div>;
}
Best for: Simpler API needs, Next.js projects
Apollo Client (GraphQL)
Specialized for GraphQL APIs:
// Apollo Client for GraphQL
import { useQuery, gql } from '@apollo/client';
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`;
function UserList() {
const { loading, error, data } = useQuery(GET_USERS);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error!</p>;
return data.users.map((user: User) => (
<div key={user.id}>{user.name}</div>
));
}
Best for: GraphQL APIs, apps with complex data requirements
Solution Selection Matrix
| Your Situation | Recommended Solution | Why |
|---|---|---|
| Small app, local state | useState + Context | No dependencies needed |
| Medium app, some global state | Zustand or Jotai | Simple, performant, small bundle |
| Large enterprise app | Redux Toolkit | Mature, predictable, great DevTools |
| Lots of API data | React Query + Zustand | Separate concerns: server vs client state |
| Complex workflows | XState | State machines model workflows naturally |
| GraphQL API | Apollo Client | Purpose-built for GraphQL |
π‘ Modern Best Practice
Hybrid approach: Use React Query (or SWR) for server state + a lightweight library like Zustand for client state. This separation of concerns is becoming the industry standard.
βοΈ Comparing State Management Libraries
Let's dive deeper into how these libraries compare across key dimensions that matter for real-world development.
Comparison Dimensions
1. Learning Curve
Valtio
SWR] -.-> A B1[Jotai
React Query
Context API] -.-> B C1[Redux Toolkit
MobX
XState] -.-> C style A fill:#4CAF50,color:#fff style C fill:#FFA726,color:#fff
- Easy: Can be productive within hours
- Moderate: Takes a day or two to grasp concepts
- Challenging: Requires dedicated learning time, often several days
2. Bundle Size Impact
| Library | Minified + Gzipped | Impact Rating |
|---|---|---|
| Zustand | ~1KB | βββββ Minimal |
| Jotai | ~3KB | βββββ Minimal |
| Valtio | ~3KB | βββββ Minimal |
| React Query | ~13KB | ββββ Small |
| SWR | ~4KB | βββββ Minimal |
| Redux Toolkit | ~20KB | βββ Moderate |
| MobX | ~16KB | βββ Moderate |
| XState | ~25KB | ββ Larger |
3. TypeScript Support
| Library | Type Safety | Type Inference | Notes |
|---|---|---|---|
| Zustand | Excellent | Full inference | Written in TS, zero config |
| Redux Toolkit | Excellent | Full inference | Best-in-class TS support |
| React Query | Excellent | Full inference | Generics work beautifully |
| Jotai | Excellent | Full inference | Atom types inferred |
| MobX | Good | Some manual typing | Decorators can be tricky |
| XState | Good | Complex typing | State machine types are verbose |
4. Developer Experience
β Excellent DX
Zustand, React Query, Jotai
- Minimal boilerplate
- Intuitive APIs
- Great documentation
- Fast feedback loop
β οΈ More Setup Required
Redux Toolkit, XState
- More concepts to learn
- More configuration needed
- Steeper initial learning curve
- But: Very powerful once mastered
5. Performance Characteristics
// Example: Zustand's selector optimization
function BearCounter() {
// Only re-renders when bears count changes
const bears = useStore(state => state.bears);
return <h1>{bears}</h1>;
}
function AddBearButton() {
// Only re-renders when addBear function changes (never)
const addBear = useStore(state => state.addBear);
return <button onClick={addBear}>Add Bear</button>;
}
| Library | Re-render Optimization | Approach |
|---|---|---|
| Zustand | Automatic with selectors | Fine-grained subscriptions |
| Jotai | Automatic | Atomic state updates |
| Valtio | Automatic | Proxy-based tracking |
| Redux Toolkit | Manual with reselect | Memoized selectors |
| Context API | Manual with memo | Context splitting |
| MobX | Automatic | Observable tracking |
6. DevTools and Debugging
- Redux Toolkit: βββββ Industry-leading Redux DevTools with time travel
- React Query: βββββ Excellent dedicated DevTools for queries
- Zustand: ββββ Redux DevTools compatible
- MobX: ββββ MobX DevTools available
- Jotai: βββ React DevTools + debug utilities
- XState: βββββ Visual state machine inspector
- Context API: ββ React DevTools only
When to Choose Each Library
Choose Zustand When:
- β You want something simple and powerful
- β Bundle size matters
- β You like hook-based APIs
- β You want fine-grained performance control
- β You're building a new app
Choose Redux Toolkit When:
- β You have a large, complex application
- β Multiple teams working on the same codebase
- β You need strict state update patterns
- β Time-travel debugging is valuable
- β Your team already knows Redux
Choose React Query When:
- β Your app is heavily data-driven
- β You fetch a lot of data from APIs
- β You need caching and background refetching
- β You want automatic loading/error states
- β Always (as server state solution)!
Choose Jotai When:
- β You like atomic state architecture
- β You need lots of derived state
- β Bottom-up state design appeals to you
- β You want built-in async atom support
Choose XState When:
- β You have complex workflows or processes
- β State machines model your domain well
- β You need to visualize state transitions
- β You want impossible states to be impossible
Choose Context API When:
- β You have 2-5 global values maximum
- β Updates are infrequent
- β You want zero dependencies
- β Values rarely change (theme, locale, auth)
π― The Winner?
There isn't one! Each library excels in different scenarios. The "best" choice depends on your specific needs:
- Most versatile: Zustand (client state) + React Query (server state)
- Enterprise standard: Redux Toolkit
- Simplest: Context API + useState
- Most innovative: Jotai, XState
ποΈ Architectural Patterns
Beyond choosing a library, how you organize your state and structure your application matters immensely. Let's explore proven architectural patterns.
Pattern 1: Feature-Based Organization
Organize code by feature rather than by file type:
src/
βββ features/
β βββ auth/
β β βββ components/
β β β βββ LoginForm.tsx
β β β βββ SignupForm.tsx
β β βββ hooks/
β β β βββ useAuth.ts
β β βββ store/
β β β βββ authStore.ts
β β βββ types/
β β β βββ auth.types.ts
β β βββ index.ts
β βββ products/
β β βββ components/
β β βββ hooks/
β β βββ store/
β β βββ types/
β βββ cart/
β βββ components/
β βββ hooks/
β βββ store/
β βββ types/
βββ shared/
β βββ components/
β βββ hooks/
β βββ utils/
βββ App.tsx
β Benefits
- Feature code is colocatedβeasy to find everything related
- Can delete entire feature folder without hunting across directories
- Clear boundaries between features
- Teams can work on different features independently
Pattern 2: Separation of Client and Server State
Client State: Managed by Zustand, Redux, Context
- Theme preference
- Sidebar open/closed
- Current tab selection
- Modal state
- Filter selections (before applying)
Server State: Managed by React Query, SWR, Apollo
- User profile data
- Product lists
- Search results
- Dashboard metrics
- Any data fetched from APIs
// Example: Hybrid approach
// Client state: Zustand
const useUIStore = create<UIStore>((set) => ({
sidebarOpen: true,
theme: 'light',
toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen })),
setTheme: (theme) => set({ theme })
}));
// Server state: React Query
function ProductList() {
const { sidebarOpen } = useUIStore(); // Client state
const { data: products } = useQuery({ // Server state
queryKey: ['products'],
queryFn: fetchProducts
});
return (
<div className={sidebarOpen ? 'with-sidebar' : 'full-width'}>
{products?.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
Pattern 3: Custom Hooks for Business Logic
Encapsulate complex logic in custom hooks:
// Custom hook pattern
function useCart() {
const { items, addItem, removeItem, clearCart } = useCartStore();
const queryClient = useQueryClient();
const addToCart = async (product: Product) => {
// Business logic
addItem(product);
// Side effects
toast.success('Added to cart');
analytics.track('add_to_cart', { productId: product.id });
};
const checkout = useMutation({
mutationFn: async () => {
const response = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ items })
});
return response.json();
},
onSuccess: () => {
clearCart();
queryClient.invalidateQueries(['orders']);
}
});
const total = useMemo(() =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0),
[items]
);
return {
items,
total,
addToCart,
removeItem,
clearCart,
checkout
};
}
// Components stay clean
function ProductCard({ product }: { product: Product }) {
const { addToCart } = useCart();
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => addToCart(product)}>Add to Cart</button>
</div>
);
}
Pattern 4: Domain-Driven Design
Structure state around your business domains:
// Domain stores
// User domain
const useUserStore = create<UserStore>(/* ... */);
// Product domain
const useProductStore = create<ProductStore>(/* ... */);
// Order domain
const useOrderStore = create<OrderStore>(/* ... */);
// Each domain is independent
// Domains communicate via actions, not shared state
Pattern 5: Normalized State
Store data in a normalized format to avoid duplication and keep it consistent:
// β Denormalized - duplication and inconsistency risk
interface DenormalizedState {
posts: Array<{
id: string;
title: string;
author: {
id: string;
name: string;
email: string;
};
comments: Array<{
id: string;
text: string;
author: {
id: string;
name: string;
email: string;
};
}>;
}>;
}
// β
Normalized - single source of truth
interface NormalizedState {
users: {
byId: Record<string, User>;
allIds: string[];
};
posts: {
byId: Record<string, Post>; // Just has authorId
allIds: string[];
};
comments: {
byId: Record<string, Comment>; // Just has authorId, postId
allIds: string[];
};
}
// Selectors can reconstruct the full object
const selectPostWithAuthor = (state: State, postId: string) => {
const post = state.posts.byId[postId];
const author = state.users.byId[post.authorId];
return { ...post, author };
};
π‘ When to Normalize
- Do normalize: When data is shared across features, updated frequently, or duplicated
- Don't normalize: Server state (React Query handles it), deeply nested read-only data, or simple apps
Pattern 6: Middleware and Side Effects
Handle side effects systematically:
// Zustand middleware example
const useStore = create<Store>()(
devtools(
persist(
immer((set) => ({
// Store definition
count: 0,
increment: () => set(state => { state.count += 1; }),
// Side effect
incrementWithLog: () => set(state => {
console.log('Before:', state.count);
state.count += 1;
console.log('After:', state.count);
// Analytics
analytics.track('count_incremented', { value: state.count });
})
})),
{
name: 'my-store',
storage: createJSONStorage(() => localStorage)
}
)
)
);
Pattern 7: Optimistic Updates
Update UI immediately, rollback if server fails:
// Optimistic update with React Query
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries(['todos']);
// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old: Todo[]) =>
[...old, newTodo]
);
// Return context with snapshot
return { previousTodos };
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries(['todos']);
}
});
π― Architecture Principles
- Colocation: Keep related code together
- Separation: Separate client and server state
- Encapsulation: Hide complexity in custom hooks
- Single Responsibility: Each store/slice does one thing
- Testability: Make state logic easy to test in isolation
π― Decision Framework
Choosing the right state management approach can feel overwhelming. Let's break it down into a systematic decision-making process.
The State Management Decision Tree
Question-Based Decision Guide
Question 1: What type of data is it?
| Data Type | Best Solution | Example |
|---|---|---|
| From API/Server | React Query, SWR, Apollo | User profile, product list, posts |
| UI/Client State | useState, Context, Zustand | Modal open, sidebar collapsed, theme |
| Form Data | React Hook Form, Formik | Registration form, checkout form |
| URL-Based | React Router, Next.js router | Current page, search filters, sort order |
Question 2: How many components need this data?
// 1 component β useState
function Counter() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
// 2-3 nearby components β Lift state up
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<Display count={count} />
<Controls setCount={setCount} />
</>
);
}
// Many distant components β Global state
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
Question 3: How often does it change?
β Rarely Changes
Examples: Theme, locale, user auth status
Solution: Context API is perfect
const ThemeContext = createContext<Theme>('light');
function App() {
const [theme, setTheme] = useState<Theme>('light');
// Changes rarely, Context is fine
return (
<ThemeContext.Provider value={theme}>
<YourApp />
</ThemeContext.Provider>
);
}
β οΈ Changes Frequently
Examples: Mouse position, animation values, real-time data
Solution: Zustand, Jotai (fine-grained updates), or useRef
// Fine-grained updates with Zustand
const useStore = create((set) => ({
x: 0,
y: 0,
setPosition: (x: number, y: number) => set({ x, y })
}));
function MouseTracker() {
// Only subscribes to what it needs
const x = useStore(state => state.x);
return <div>X: {x}</div>;
}
Question 4: How complex is the state logic?
| Complexity | Indicators | Best Solution |
|---|---|---|
| Simple | 1-3 values, independent updates | useState |
| Moderate | 5-10 values, some relationships | useReducer, Zustand |
| Complex | Many values, interdependencies, workflows | Redux Toolkit, XState |
// Simple β useState
const [isOpen, setIsOpen] = useState(false);
// Moderate β useReducer
const [state, dispatch] = useReducer(reducer, initialState);
// Complex β Redux Toolkit
const store = configureStore({
reducer: {
auth: authReducer,
products: productsReducer,
cart: cartReducer,
orders: ordersReducer
}
});
Question 5: What's your team's experience?
π‘ Team Considerations
- Junior team: Stick with built-in React features or Zustand (simple API)
- Mixed experience: Zustand or Jotai (gradual learning curve)
- Experienced team: Any solution, choose based on requirements
- Redux veterans: Redux Toolkit is productive and familiar
Real-World Scenarios
Scenario 1: Building a Blog
Requirements: Display posts, comments, user authentication
Recommended Stack:
- Server state (posts, comments): React Query
- Auth state: Context API (rarely changes)
- UI state (theme, sidebar): Context or useState
// Blog state architecture
// 1. Auth context (global, changes rarely)
const AuthContext = createContext<AuthContextType | null>(null);
// 2. React Query for posts
function BlogList() {
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts
});
return <div>{posts?.map(post => <PostCard key={post.id} post={post} />)}</div>;
}
// 3. Local state for UI
function PostCard({ post }: { post: Post }) {
const [expanded, setExpanded] = useState(false);
return <div onClick={() => setExpanded(!expanded)}>...</div>;
}
Scenario 2: E-commerce Application
Requirements: Products, cart, orders, user profile, filters
Recommended Stack:
- Server state (products, orders): React Query
- Cart state: Zustand (shared, frequently updated)
- Filter state: URL params (shareable, bookmarkable)
- Auth state: Context API
// E-commerce state architecture
// 1. Cart store (Zustand)
const useCartStore = create<CartStore>((set) => ({
items: [],
addItem: (product) => set((state) => ({
items: [...state.items, { ...product, quantity: 1 }]
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(item => item.id !== id)
}))
}));
// 2. Products from server (React Query)
function ProductList() {
const [searchParams] = useSearchParams();
const category = searchParams.get('category') || 'all';
const { data: products } = useQuery({
queryKey: ['products', category],
queryFn: () => fetchProducts(category)
});
const { addItem } = useCartStore();
return products?.map(p => (
<ProductCard
key={p.id}
product={p}
onAddToCart={() => addItem(p)}
/>
));
}
Scenario 3: Admin Dashboard
Requirements: Complex forms, tables, charts, real-time updates, multiple user roles
Recommended Stack:
- Server state: React Query with real-time subscriptions
- Global UI state: Redux Toolkit (complex, predictable updates needed)
- Form state: React Hook Form
- Auth & permissions: Context API
Scenario 4: Real-Time Chat Application
Requirements: Messages, presence, notifications, typing indicators
Recommended Stack:
- Messages: React Query with WebSocket integration
- Presence/typing: Zustand (frequent updates, global)
- UI state: Local useState
Migration Strategies
β οΈ When to Migrate
Signs you might need to refactor your state management:
- Prop drilling through 5+ levels consistently
- Performance issues from Context re-renders
- Difficulty debugging state changes
- Team spending too much time on state-related bugs
- Code duplication in state update logic
Migration Path: Context β Zustand
// Before: Context (causing re-render issues)
const AppContext = createContext<AppState | null>(null);
function AppProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState('light');
const [cart, setCart] = useState<CartItem[]>([]);
// Every component re-renders when ANY value changes!
const value = { user, setUser, theme, setTheme, cart, setCart };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
// After: Zustand (fine-grained subscriptions)
const useStore = create<AppStore>((set) => ({
user: null,
theme: 'light',
cart: [],
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
addToCart: (item) => set((state) => ({ cart: [...state.cart, item] }))
}));
// Components only re-render when THEIR data changes
function ThemeToggle() {
const theme = useStore(state => state.theme); // Only subscribes to theme
const setTheme = useStore(state => state.setTheme);
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>Toggle</button>;
}
Gradual Migration Strategy
- Identify pain points: Which Context is causing the most re-renders?
- Start small: Migrate one feature/store at a time
- Run both systems: Zustand and Context can coexist during migration
- Test thoroughly: Ensure behavior is identical
- Monitor performance: Verify improvements with React DevTools Profiler
- Complete migration: Remove old Context code once everything is migrated
π― Decision Framework Summary
- Always start with the simplest solution (useState)
- Separate concerns (server state vs client state)
- Choose based on actual needs, not trends
- Consider team experience and learning curve
- Plan for growth, but don't over-engineer
- Refactor when you feel pain, not before
β Best Practices
Follow these proven practices to build maintainable, performant state management systems.
1. Separate Concerns
β Do This
// Server state - React Query
const { data: user } = useQuery(['user', userId], fetchUser);
// Client state - Zustand
const theme = useStore(state => state.theme);
// Form state - React Hook Form
const { register, handleSubmit } = useForm();
// URL state - React Router
const [searchParams] = useSearchParams();
β Don't Do This
// Mixing server state in Redux (now you manage cache yourself!)
dispatch(fetchUser(userId)); // Manual async action
const user = useSelector(state => state.user); // Manual cache management
// Putting everything in Context (performance issues)
const { user, products, cart, theme, locale } = useContext(EverythingContext);
2. Keep State Close to Where It's Used
// β
Good: Local state for accordion
function Accordion({ items }: { items: Item[] }) {
const [openIndex, setOpenIndex] = useState(0);
// State only matters to this component
return <div>{/* ... */}</div>;
}
// β Bad: Unnecessary global state
const useStore = create((set) => ({
accordionOpenIndex: 0, // Why is this global?
setAccordionOpenIndex: (index: number) => set({ accordionOpenIndex: index })
}));
3. Use TypeScript Properly
// β
Good: Fully typed store
interface UserStore {
user: User | null;
setUser: (user: User | null) => void;
logout: () => void;
}
const useUserStore = create<UserStore>((set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null })
}));
// β Bad: Any types
const useStore = create((set: any) => ({
user: null as any,
setUser: (user: any) => set({ user })
}));
4. Avoid Derived State
// β Bad: Storing derived state
const useStore = create((set) => ({
items: [],
total: 0, // Derived from items!
addItem: (item) => set((state) => ({
items: [...state.items, item],
total: state.total + item.price // Easy to get out of sync!
}))
}));
// β
Good: Calculate on the fly
const useStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
}))
}));
// Compute when needed
function Cart() {
const items = useStore(state => state.items);
const total = items.reduce((sum, item) => sum + item.price, 0);
return <div>Total: ${total}</div>;
}
// Or use a selector with memoization
const useTotal = () => useStore(
state => state.items.reduce((sum, item) => sum + item.price, 0)
);
5. Normalize Related Data
// β
Good: Normalized structure
interface NormalizedState {
users: {
byId: Record<string, User>;
allIds: string[];
};
posts: {
byId: Record<string, Post>;
allIds: string[];
};
}
// Easy to update a single user
const updateUser = (userId: string, updates: Partial<User>) => {
set((state) => ({
users: {
...state.users,
byId: {
...state.users.byId,
[userId]: { ...state.users.byId[userId], ...updates }
}
}
}));
};
6. Use Selectors for Performance
// β Bad: Component re-renders when ANY store property changes
function UserName() {
const store = useStore(); // Subscribes to everything!
return <div>{store.user?.name}</div>;
}
// β
Good: Only re-renders when user.name changes
function UserName() {
const userName = useStore(state => state.user?.name);
return <div>{userName}</div>;
}
// β
Even better: Custom selector hook
const useUserName = () => useStore(state => state.user?.name);
function UserName() {
const userName = useUserName();
return <div>{userName}</div>;
}
7. Handle Async Operations Properly
// β
Good: Proper async handling with React Query
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
});
if (isLoading) return <Spinner />;
if (error) return <Error error={error} />;
return <UserProfile user={data} />;
// β
Good: Async actions in Zustand
const useStore = create<Store>((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id: string) => {
set({ loading: true, error: null });
try {
const user = await fetchUser(id);
set({ user, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
}
}));
8. Don't Over-Optimize Prematurely
β οΈ Remember
React is fast. Most apps don't need aggressive optimization. Profile first, optimize second.
- Don't wrap everything in useMemo/useCallback without reason
- Don't split Context into 20 tiny contexts prematurely
- Don't reach for Redux if Context works fine
- Measure with React DevTools Profiler before optimizing
9. Write Testable State Logic
// β
Good: Testable Zustand store
import { create } from 'zustand';
export const createCounterStore = (initialCount = 0) => {
return create<CounterStore>((set) => ({
count: initialCount,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 }))
}));
};
// Easy to test!
describe('Counter Store', () => {
it('increments count', () => {
const useStore = createCounterStore(0);
const { increment } = useStore.getState();
increment();
expect(useStore.getState().count).toBe(1);
});
});
10. Document Your State Architecture
/**
* Application State Architecture
*
* Server State (React Query):
* - User data (/api/user)
* - Products (/api/products)
* - Orders (/api/orders)
*
* Client State (Zustand):
* - Cart (useCartStore): Shopping cart items
* - UI (useUIStore): Theme, sidebar state, modals
*
* Form State (React Hook Form):
* - Checkout form
* - Profile edit form
*
* URL State (React Router):
* - Search filters
* - Pagination
* - Selected category
*/
π― Best Practices Checklist
- β Separate server and client state
- β Keep state as local as possible
- β Use TypeScript for type safety
- β Avoid storing derived state
- β Normalize when needed
- β Use selectors for performance
- β Handle async properly
- β Profile before optimizing
- β Write testable state logic
- β Document your architecture
π Summary
π What You've Learned
Congratulations! You now have a comprehensive understanding of state management in React applications. Let's review the key concepts:
Key Takeaways
- State Management is About Trade-offs: There's no perfect solution for all cases. Choose based on your specific needs.
- Four Types of State:
- Local: Component-specific (useState)
- Shared: Multiple components need it (Context, Zustand, Redux)
- Server: From APIs (React Query, SWR)
- URL: In the URL (React Router)
- Start Simple, Scale When Needed: Begin with useState and Context, only add complexity when you feel real pain points.
- Separate Server and Client State: This is the single biggest improvement you can make to state management.
- Modern Recommendation:
- Server state: React Query or SWR
- Client state: Zustand for simplicity, Redux Toolkit for complex apps
- Forms: React Hook Form
- URL state: React Router
Comparison at a Glance
| Solution | Best For | Size | Learning Curve |
|---|---|---|---|
| useState/Context | Small to medium apps | 0KB (built-in) | β Very Easy |
| Zustand | Most applications | 1KB | β Very Easy |
| Jotai | Atomic state needs | 3KB | ββ Easy |
| Redux Toolkit | Large enterprise apps | 20KB | βββ Moderate |
| React Query | Server state (always!) | 13KB | ββ Easy |
| XState | Complex workflows | 25KB | ββββ Challenging |
Next Steps in Module 8
Now that you understand the landscape, here's what's coming:
- Lesson 8.2: Hands-on with Zustand - Build real applications with this popular library
- Lesson 8.3: Redux Toolkit - Master enterprise-grade state management
- Lesson 8.4: React Query - Transform how you handle server state
- Lesson 8.5: Architecture Best Practices - Organize your code like a pro
Practical Exercise
ποΈ Practice: Audit Your Current Project
Take a project you're working on (or one from earlier modules) and answer these questions:
- What state do I have? Categorize each piece (local, shared, server, URL)
- Am I experiencing any pain points? (Prop drilling, performance, complexity)
- Is my server state separate from client state?
- What would I change about my current state management?
- Based on this lesson, what's the best solution for my needs?
Additional Resources
- Zustand Documentation
- TanStack Query (React Query) Docs
- Redux Toolkit Documentation
- Jotai Documentation
- XState Documentation
- Kent C. Dodds: Application State Management
π Great Job!
You now understand the state management landscape!
You're equipped to make informed decisions about which tools to use for your applications. In the next lesson, we'll get hands-on with Zustand and build real state management solutions.
Ready to start building? Let's go! π
π― Quick Quiz
Test your understanding of state management concepts!
Question 1: What type of state is data fetched from an API?
Question 2: When should you choose Redux Toolkit over Zustand?
Question 3: What is the main problem with using Context API for frequently changing values?
Question 4: What is the principle of "colocation" in state management?
Question 5: Why is React Query considered a game-changer for state management?