Skip to main content

πŸ—οΈ 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:

graph TB A[State Management Challenges] --> B[Synchronization] A --> C[Performance] A --> D[Debugging] A --> E[Maintainability] B --> B1[Multiple components need same data] B --> B2[Keeping UI in sync with state] B --> B3[Avoiding stale data] C --> C1[Unnecessary re-renders] C --> C2[Large component trees] C --> C3[Frequent updates] D --> D1[Where did this value come from?] D --> D2[Why did this component re-render?] D --> D3[What changed this state?] E --> E1[Prop drilling] E --> E2[Scattered state logic] E --> E3[Hard to understand data flow] style A fill:#667eea,color:#fff

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:

  1. Predictability: Given the same state and actions, the application behaves the same way
  2. Maintainability: Easy to understand, modify, and extend
  3. Debuggability: Easy to trace state changes and identify bugs
  4. Performance: Efficient updates without unnecessary re-renders
  5. Developer Experience: Pleasant to work with, good TypeScript support
  6. 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

graph LR A[Application State] --> B[Local State] A --> C[Shared State] A --> D[Server State] A --> E[URL State] B --> B1[Component-specific] C --> C1[Multiple components] D --> D1[From backend APIs] E --> E1[From URL params] style A fill:#667eea,color:#fff

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:

  1. Easier to understand: The state and the components that use it are in the same file
  2. Better performance: State changes only re-render the components that need to update
  3. Simpler testing: Components with local state are self-contained and easier to test
  4. Easier refactoring: Moving or removing components doesn't affect distant parts of the app
  5. 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:

graph TB A[Parent Component
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

  1. Start: Keep state in the component that needs it
  2. If siblings need it: Lift to common parent
  3. If prop drilling 3+ levels: Try component composition
  4. 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

graph LR A[Built-in React] --> B[Context + Hooks] B --> C[Lightweight Libraries] C --> D[Full-Featured Libraries] D --> E[Server State Libraries] A1[useState
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

graph LR A[Easy] --> B[Moderate] --> C[Challenging] A1[Zustand
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

graph TB A[Application State] --> B[Client State] A --> C[Server State] B --> B1[UI State: Zustand/Redux] B --> B2[Form State: React Hook Form] B --> B3[URL State: Router] C --> C1[API Data: React Query] C --> C2[Real-time: WebSocket + Query] C --> C3[GraphQL: Apollo Client] style A fill:#667eea,color:#fff style B fill:#4CAF50,color:#fff style C fill:#FFA726,color:#fff

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

graph TD A[Start: Need to manage state] --> B{Is it server data?} B -->|Yes| C[React Query or SWR] B -->|No| D{How many components need it?} D -->|Just one| E[Local useState] D -->|2-3 nearby| F[Lift state up] D -->|Many, far apart| G{How complex?} G -->|Simple| H{How many global values?} G -->|Very complex| I[Redux Toolkit or XState] H -->|1-3 values| J[Context API] H -->|Many values| K[Zustand or Jotai] style A fill:#667eea,color:#fff style C fill:#4CAF50,color:#fff style E fill:#4CAF50,color:#fff style J fill:#FFA726,color:#fff style K fill:#2196F3,color:#fff style I fill:#9C27B0,color:#fff

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

  1. Identify pain points: Which Context is causing the most re-renders?
  2. Start small: Migrate one feature/store at a time
  3. Run both systems: Zustand and Context can coexist during migration
  4. Test thoroughly: Ensure behavior is identical
  5. Monitor performance: Verify improvements with React DevTools Profiler
  6. Complete migration: Remove old Context code once everything is migrated

🎯 Decision Framework Summary

  1. Always start with the simplest solution (useState)
  2. Separate concerns (server state vs client state)
  3. Choose based on actual needs, not trends
  4. Consider team experience and learning curve
  5. Plan for growth, but don't over-engineer
  6. 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

  1. State Management is About Trade-offs: There's no perfect solution for all cases. Choose based on your specific needs.
  2. 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)
  3. Start Simple, Scale When Needed: Begin with useState and Context, only add complexity when you feel real pain points.
  4. Separate Server and Client State: This is the single biggest improvement you can make to state management.
  5. 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:

  1. What state do I have? Categorize each piece (local, shared, server, URL)
  2. Am I experiencing any pain points? (Prop drilling, performance, complexity)
  3. Is my server state separate from client state?
  4. What would I change about my current state management?
  5. Based on this lesson, what's the best solution for my needs?

Additional Resources

πŸŽ‰ 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?