04-state-management-and-data-flow.md

Module 04: State Management & Data Flow β€” Managing State at Scale

Estimated Time: 18-22 hours Difficulty: πŸ”΄ Advanced Prerequisites: Module 02 (Hooks), Module 03 (Patterns) Interview Weight: ⭐⭐⭐⭐⭐ (Redux questions are extremely common)


πŸ“‹ Table of Contents

  1. The State Management Landscape
  2. Types of State in React Apps
  3. Local State Patterns
  4. Context API at Scale
  5. Redux Deep Dive
  6. Redux Toolkit (Modern Redux)
  7. Zustand β€” Lightweight Alternative
  8. Server State: React Query / TanStack Query
  9. When to Use What β€” Decision Framework
  10. Common Mistakes & Gotchas
  11. Practice Problems
  12. Interview Questions
  13. "What's the Output?" Challenges

1. The State Management Landscape

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ The State Management Spectrum β”‚ β”‚ β”‚ β”‚ Simple ◄──────────────────────────────────► Complex β”‚ β”‚ β”‚ β”‚ useState Context Zustand Redux Redux + β”‚ β”‚ useReducer API Jotai Toolkit Saga/Thunk β”‚ β”‚ Recoil β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚Local β”‚ β”‚Shared β”‚ β”‚Global β”‚ β”‚Enterprise-scale β”‚ β”‚ β”‚ β”‚State β”‚ β”‚Subtree β”‚ β”‚Store β”‚ β”‚with middleware β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ Use the SIMPLEST tool that solves your problem. β”‚ β”‚ Don't use Redux for a todo app! β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

2. Types of State in React Apps

Understanding WHAT kind of state you're dealing with determines HOW to manage it:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ State Type β”‚ Examples β”‚ Best Tool β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ UI State β”‚ Modal open/closed β”‚ useState β”‚ β”‚ (local) β”‚ Tab selection β”‚ useReducer β”‚ β”‚ β”‚ Form input values β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Shared UI State β”‚ Theme (dark/light) β”‚ Context API β”‚ β”‚ (cross-comp) β”‚ User auth status β”‚ Zustand β”‚ β”‚ β”‚ Sidebar collapsed β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Server State β”‚ User profile data β”‚ React Query β”‚ β”‚ (cached) β”‚ Product listings β”‚ SWR β”‚ β”‚ β”‚ Search results β”‚ RTK Query β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Complex Client β”‚ Shopping cart β”‚ Redux Toolkit β”‚ β”‚ State β”‚ Multi-step wizard β”‚ Zustand β”‚ β”‚ β”‚ Collaborative β”‚ β”‚ β”‚ β”‚ editing β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ URL State β”‚ Current page β”‚ React Router β”‚ β”‚ β”‚ Search params β”‚ URL search paramsβ”‚ β”‚ β”‚ Filters β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Form State β”‚ Validation β”‚ React Hook Form β”‚ β”‚ β”‚ Touched/dirty β”‚ Formik β”‚ β”‚ β”‚ Submission status β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ’‘ Key Insight: The biggest mistake people make is putting ALL state in one place (usually Redux). Different types of state belong in different places. Server state should NOT be in Redux.


3. Local State Patterns

Lifting State Up (Revisited)

// When siblings need to share state, lift it to their common parent function TemperatureCalculator() { const [temp, setTemp] = useState(''); const [scale, setScale] = useState('celsius'); const celsius = scale === 'fahrenheit' ? ((parseFloat(temp) - 32) * 5/9) : parseFloat(temp); const fahrenheit = scale === 'celsius' ? ((parseFloat(temp) * 9/5) + 32) : parseFloat(temp); return ( <div> <TemperatureInput label="Celsius" value={scale === 'celsius' ? temp : celsius.toFixed(2)} onChange={(val) => { setTemp(val); setScale('celsius'); }} /> <TemperatureInput label="Fahrenheit" value={scale === 'fahrenheit' ? temp : fahrenheit.toFixed(2)} onChange={(val) => { setTemp(val); setScale('fahrenheit'); }} /> </div> ); } function TemperatureInput({ label, value, onChange }) { return ( <label> {label}: <input value={value} onChange={e => onChange(e.target.value)} /> </label> ); }

State Colocation β€” Keep State Close to Where It's Used

// ❌ Over-lifting: State is too far from where it's used function App() { const [searchTerm, setSearchTerm] = useState(''); // Only used in SearchPage! const [sortOrder, setSortOrder] = useState('asc'); // Only used in ProductList! return ( <Layout> <SearchPage searchTerm={searchTerm} setSearchTerm={setSearchTerm} /> <ProductList sortOrder={sortOrder} setSortOrder={setSortOrder} /> </Layout> ); } // βœ… Colocated: State lives where it's used function App() { return ( <Layout> <SearchPage /> {/* Manages its own search state */} <ProductList /> {/* Manages its own sort state */} </Layout> ); }

4. Context API at Scale

When Context Works Well

βœ… Good use cases for Context: β”œβ”€β”€ Theme (light/dark) β”œβ”€β”€ Current user / authentication β”œβ”€β”€ Locale / internationalization β”œβ”€β”€ Feature flags └── Any data that rarely changes and is needed by many components

When Context Falls Short

❌ Poor use cases for Context: β”œβ”€β”€ Frequently changing data (causes re-renders of ALL consumers) β”œβ”€β”€ Complex state logic with many actions β”œβ”€β”€ State that needs middleware (logging, persistence) β”œβ”€β”€ State that needs time-travel debugging └── High-performance scenarios with many consumers

Optimizing Context Performance

import { createContext, useContext, useState, useMemo, memo } from 'react'; // ❌ Naive approach β€” everything re-renders on any change const AppContext = createContext(); function AppProvider({ children }) { const [user, setUser] = useState(null); const [theme, setTheme] = useState('light'); // New object every render = ALL consumers re-render return ( <AppContext.Provider value={{ user, setUser, theme, setTheme }}> {children} </AppContext.Provider> ); } // βœ… Optimized β€” separate contexts + memoized values const UserContext = createContext(); const ThemeContext = createContext(); function UserProvider({ children }) { const [user, setUser] = useState(null); // Memoize the value object const value = useMemo(() => ({ user, setUser }), [user]); return <UserContext.Provider value={value}>{children}</UserContext.Provider>; } function ThemeProvider({ children }) { 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 user consumers! function App() { return ( <UserProvider> <ThemeProvider> <MainApp /> </ThemeProvider> </UserProvider> ); }

5. Redux Deep Dive

What is Redux?

Redux is a predictable state container for JavaScript apps. It stores all application state in a single object (the "store") and uses a strict pattern to update it.

Real-World Analogy

Think of Redux as a bank:

  • Store = The bank vault (holds all the money/state)
  • Action = A withdrawal/deposit slip (describes what you want to do)
  • Reducer = The bank teller (processes the slip and updates the vault)
  • Dispatch = Handing the slip to the teller (triggering the update)
  • Selector = Your bank statement (reading specific data from the vault)

The Three Principles

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Redux Three Principles: β”‚ β”‚ β”‚ β”‚ 1. Single Source of Truth β”‚ β”‚ The entire app state lives in ONE store. β”‚ β”‚ Makes debugging and serialization easy. β”‚ β”‚ β”‚ β”‚ 2. State is Read-Only β”‚ β”‚ You can't modify state directly. β”‚ β”‚ The only way to change state is to DISPATCH β”‚ β”‚ an ACTION β€” a plain object describing what β”‚ β”‚ happened. β”‚ β”‚ β”‚ β”‚ 3. Changes via Pure Functions β”‚ β”‚ Reducers are pure functions: β”‚ β”‚ (previousState, action) β†’ newState β”‚ β”‚ No side effects, no mutations, always β”‚ β”‚ predictable. β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Redux Data Flow

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Redux Data Flow β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ UI β”‚ 1. User clicks "Add to Cart" β”‚ β”‚ β”‚ (React) │────────────────────┐ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β–² β–Ό β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ β”‚ Action β”‚ β”‚ β”‚ 6. UI re-renders β”‚ Creator β”‚ β”‚ β”‚ with new data β”‚ addToCart() β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ 2. Returns action: β”‚ β”‚ β”‚ { type: 'ADD_TO_CART', β”‚ β”‚ β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β” payload: { id: 1 } } β”‚ β”‚ β”‚ Store β”‚ β”‚ β”‚ β”‚ β”‚ .getStateβ”‚ 3. dispatch(action) β”‚ β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ 5. Store updates β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ and notifies β”‚ Reducer β”‚ β”‚ β”‚ subscribers β”‚ β”‚ β”‚ β”‚ β–² β”‚ (state, action) β”‚ β”‚ β”‚ β”‚ β†’ newState β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ 4. Returns new state β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Vanilla Redux Example (Without Toolkit)

import { createStore } from 'redux'; // Action Types const INCREMENT = 'counter/increment'; const DECREMENT = 'counter/decrement'; const RESET = 'counter/reset'; // Action Creators const increment = () => ({ type: INCREMENT }); const decrement = () => ({ type: DECREMENT }); const reset = () => ({ type: RESET }); // Reducer const initialState = { count: 0 }; function counterReducer(state = initialState, action) { switch (action.type) { case INCREMENT: return { ...state, count: state.count + 1 }; case DECREMENT: return { ...state, count: state.count - 1 }; case RESET: return { ...state, count: 0 }; default: return state; } } // Create Store const store = createStore(counterReducer); // Subscribe to changes store.subscribe(() => { console.log('State changed:', store.getState()); }); // Dispatch actions store.dispatch(increment()); // { count: 1 } store.dispatch(increment()); // { count: 2 } store.dispatch(decrement()); // { count: 1 } store.dispatch(reset()); // { count: 0 }

Connecting Redux to React (Legacy β€” connect)

import { connect } from 'react-redux'; // The old way β€” using connect HOC function Counter({ count, increment, decrement }) { return ( <div> <p>{count}</p> <button onClick={increment}>+</button> <button onClick={decrement}>-</button> </div> ); } // mapStateToProps: what state does this component need? const mapStateToProps = (state) => ({ count: state.counter.count, }); // mapDispatchToProps: what actions does this component dispatch? const mapDispatchToProps = { increment, decrement, }; export default connect(mapStateToProps, mapDispatchToProps)(Counter);

Modern Redux with Hooks

import { useSelector, useDispatch } from 'react-redux'; function Counter() { // useSelector replaces mapStateToProps const count = useSelector(state => state.counter.count); // useDispatch replaces mapDispatchToProps const dispatch = useDispatch(); return ( <div> <p>{count}</p> <button onClick={() => dispatch(increment())}>+</button> <button onClick={() => dispatch(decrement())}>-</button> </div> ); }

Redux Middleware

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Middleware sits between dispatch and reducer: β”‚ β”‚ β”‚ β”‚ dispatch(action) β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Logger │──▢│ Thunk │──▢│ Reducer β”‚ β”‚ β”‚ β”‚ Middlewareβ”‚ β”‚Middlewareβ”‚ β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ Middleware can: β”‚ β”‚ - Log every action (redux-logger) β”‚ β”‚ - Handle async actions (redux-thunk, redux-saga) β”‚ β”‚ - Persist state (redux-persist) β”‚ β”‚ - Report errors (sentry-redux) β”‚ β”‚ - Cancel, delay, or modify actions β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

6. Redux Toolkit (Modern Redux)

Why Redux Toolkit?

Redux Toolkit (RTK) is the official, recommended way to write Redux. It solves the main complaints about vanilla Redux:

Vanilla Redux problems β†’ Redux Toolkit solutions: β”œβ”€β”€ Too much boilerplate β†’ createSlice auto-generates actions β”œβ”€β”€ Need many packages β†’ All included in @reduxjs/toolkit β”œβ”€β”€ Immutable updates hard β†’ Uses Immer (write "mutable" code safely) β”œβ”€β”€ Store setup complex β†’ configureStore does it all β”œβ”€β”€ No async pattern β†’ createAsyncThunk built-in └── No selectors β†’ Reselect included

Setting Up Redux Toolkit

npm install @reduxjs/toolkit react-redux

Creating a Slice

// features/counter/counterSlice.js import { createSlice } from '@reduxjs/toolkit'; const counterSlice = createSlice({ name: 'counter', // Prefix for action types initialState: { count: 0, status: 'idle', }, reducers: { // Each reducer automatically generates an action creator! increment(state) { state.count += 1; // βœ… "Mutation" is OK β€” Immer handles immutability! }, decrement(state) { state.count -= 1; }, incrementByAmount(state, action) { state.count += action.payload; }, reset(state) { state.count = 0; }, }, }); // Auto-generated action creators export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions; // Selectors export const selectCount = (state) => state.counter.count; // The reducer export default counterSlice.reducer;

πŸ” Under the Hood: Immer

// How Immer makes "mutations" safe: // What you write: increment(state) { state.count += 1; // Looks like mutation! } // What Immer does behind the scenes: // 1. Creates a "draft" (proxy) of the state // 2. Tracks all changes you make to the draft // 3. Produces a new immutable state based on the changes // Equivalent to: increment(state) { return { ...state, count: state.count + 1 }; // Immer does this for you } // ⚠️ Important rules with Immer: // Either mutate the draft OR return a new object β€” never both! // βœ… Mutate increment(state) { state.count += 1; } // βœ… Return new object increment(state) { return { ...state, count: state.count + 1 }; } // ❌ NEVER do both increment(state) { state.count += 1; return state; // Ambiguous! Don't mix patterns }

Configuring the Store

// store/store.js import { configureStore } from '@reduxjs/toolkit'; import counterReducer from '../features/counter/counterSlice'; import todosReducer from '../features/todos/todosSlice'; import userReducer from '../features/user/userSlice'; const store = configureStore({ reducer: { counter: counterReducer, todos: todosReducer, user: userReducer, }, // DevTools are automatically enabled in development! // Thunk middleware is automatically included! }); export default store;

Providing the Store to React

// main.jsx import { Provider } from 'react-redux'; import store from './store/store'; ReactDOM.createRoot(document.getElementById('root')).render( <Provider store={store}> <App /> </Provider> );

Async Actions with createAsyncThunk

// features/users/usersSlice.js import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; // Async thunk β€” handles pending/fulfilled/rejected automatically export const fetchUsers = createAsyncThunk( 'users/fetchUsers', // Action type prefix async (_, { rejectWithValue }) => { try { const response = await fetch('/api/users'); if (!response.ok) throw new Error('Failed to fetch'); return await response.json(); // This becomes action.payload } catch (error) { return rejectWithValue(error.message); } } ); export const deleteUser = createAsyncThunk( 'users/deleteUser', async (userId, { rejectWithValue }) => { try { await fetch(`/api/users/${userId}`, { method: 'DELETE' }); return userId; // Return the ID so reducer can remove it } catch (error) { return rejectWithValue(error.message); } } ); const usersSlice = createSlice({ name: 'users', initialState: { items: [], status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' error: null, }, reducers: { // Regular synchronous reducers here }, extraReducers: (builder) => { builder // Fetch Users .addCase(fetchUsers.pending, (state) => { state.status = 'loading'; state.error = null; }) .addCase(fetchUsers.fulfilled, (state, action) => { state.status = 'succeeded'; state.items = action.payload; }) .addCase(fetchUsers.rejected, (state, action) => { state.status = 'failed'; state.error = action.payload; }) // Delete User .addCase(deleteUser.fulfilled, (state, action) => { state.items = state.items.filter(u => u.id !== action.payload); }); }, }); export const selectUsers = (state) => state.users.items; export const selectUsersStatus = (state) => state.users.status; export const selectUsersError = (state) => state.users.error; export default usersSlice.reducer;

Using in Components

import { useSelector, useDispatch } from 'react-redux'; import { fetchUsers, deleteUser, selectUsers, selectUsersStatus, selectUsersError } from './usersSlice'; import { useEffect } from 'react'; function UserList() { const dispatch = useDispatch(); const users = useSelector(selectUsers); const status = useSelector(selectUsersStatus); const error = useSelector(selectUsersError); useEffect(() => { if (status === 'idle') { dispatch(fetchUsers()); } }, [status, dispatch]); if (status === 'loading') return <p>Loading users...</p>; if (status === 'failed') return <p>Error: {error}</p>; return ( <ul> {users.map(user => ( <li key={user.id}> {user.name} <button onClick={() => dispatch(deleteUser(user.id))}> Delete </button> </li> ))} </ul> ); }

7. Zustand β€” Lightweight Alternative

Why Zustand?

Zustand is a minimal state management library. No boilerplate, no providers, no reducers:

Redux Toolkit: β”œβ”€β”€ createSlice with reducers β”œβ”€β”€ configureStore β”œβ”€β”€ <Provider store={store}> β”œβ”€β”€ useSelector + useDispatch β”œβ”€β”€ createAsyncThunk for async └── ~15 files for a feature Zustand: β”œβ”€β”€ create() with a function β”œβ”€β”€ Use the hook directly └── ~1 file for a feature

Zustand Example

import { create } from 'zustand'; // Create a store β€” it's just a function! const useStore = create((set, get) => ({ // State count: 0, users: [], isLoading: false, // Actions (just functions that call set) increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), reset: () => set({ count: 0 }), // Async actions β€” no middleware needed! fetchUsers: async () => { set({ isLoading: true }); try { const response = await fetch('/api/users'); const users = await response.json(); set({ users, isLoading: false }); } catch (error) { set({ isLoading: false }); console.error('Failed to fetch users:', error); } }, // Access other state values incrementIfBelowMax: (max) => { const { count } = get(); if (count < max) { set({ count: count + 1 }); } }, })); // Usage in components β€” no Provider needed! function Counter() { const count = useStore(state => state.count); const increment = useStore(state => state.increment); return <button onClick={increment}>{count}</button>; } function UserList() { const { users, isLoading, fetchUsers } = useStore(); useEffect(() => { fetchUsers(); }, [fetchUsers]); if (isLoading) return <p>Loading...</p>; return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>; }

Zustand with Middleware

import { create } from 'zustand'; import { persist, devtools } from 'zustand/middleware'; const useStore = create( devtools( // Redux DevTools integration persist( // Persist to localStorage (set) => ({ theme: 'light', toggleTheme: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light', })), }), { name: 'theme-store' } // localStorage key ) ) );

πŸ“Š Redux Toolkit vs Zustand

FeatureRedux ToolkitZustand
Bundle size~11KB~1.5KB
BoilerplateModerateMinimal
Provider needed?YesNo
DevToolsBuilt-inMiddleware
MiddlewareBuilt-in (thunk)Plugin-based
Learning curveSteeperVery gentle
TypeScriptGoodExcellent
EcosystemHugeGrowing
Time-travel debugYesVia DevTools middleware
Best forLarge teams, complex appsSmall-medium apps

8. Server State: React Query / TanStack Query

The Problem with useEffect for Data Fetching

// ❌ The naive approach has MANY problems: function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); fetch(`/api/users/${userId}`) .then(res => res.json()) .then(data => { if (!cancelled) setUser(data); }) .catch(err => { if (!cancelled) setError(err); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [userId]); // Missing features: // ❌ No caching β€” re-fetches on every mount // ❌ No background refetching β€” data gets stale // ❌ No deduplication β€” multiple components fetch same data // ❌ No retry on failure // ❌ No pagination support // ❌ No optimistic updates // ❌ No garbage collection // ❌ No request cancellation on fast navigation }

React Query Solves All of This

npm install @tanstack/react-query
// Setup import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, // Data is "fresh" for 5 minutes gcTime: 10 * 60 * 1000, // Cache garbage collected after 10 min retry: 3, // Retry failed requests 3 times refetchOnWindowFocus: true, // Refetch when user returns to tab }, }, }); function App() { return ( <QueryClientProvider client={queryClient}> <MainApp /> </QueryClientProvider> ); }

Basic Query

import { useQuery } from '@tanstack/react-query'; function UserProfile({ userId }) { const { data: user, isLoading, isError, error, isFetching, // True when fetching (including background refetch) isStale, // True when data is stale refetch, // Manually trigger refetch } = useQuery({ queryKey: ['user', userId], // Unique key for caching queryFn: async () => { // The fetch function const res = await fetch(`/api/users/${userId}`); if (!res.ok) throw new Error('Failed to fetch user'); return res.json(); }, staleTime: 5 * 60 * 1000, // Consider data fresh for 5 min enabled: !!userId, // Only fetch if userId exists }); if (isLoading) return <p>Loading...</p>; if (isError) return <p>Error: {error.message}</p>; return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> {isFetching && <small>Updating...</small>} </div> ); }

Mutations (Create, Update, Delete)

import { useMutation, useQueryClient } from '@tanstack/react-query'; function CreateUserForm() { const queryClient = useQueryClient(); const createUser = useMutation({ mutationFn: async (newUser) => { const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newUser), }); if (!res.ok) throw new Error('Failed to create user'); return res.json(); }, onSuccess: (newUser) => { // Invalidate the users query to trigger a refetch queryClient.invalidateQueries({ queryKey: ['users'] }); // Or optimistically update the cache // queryClient.setQueryData(['users'], (old) => [...old, newUser]); }, onError: (error) => { alert(`Error: ${error.message}`); }, }); const handleSubmit = (e) => { e.preventDefault(); const formData = new FormData(e.target); createUser.mutate({ name: formData.get('name'), email: formData.get('email'), }); }; return ( <form onSubmit={handleSubmit}> <input name="name" placeholder="Name" required /> <input name="email" type="email" placeholder="Email" required /> <button type="submit" disabled={createUser.isPending}> {createUser.isPending ? 'Creating...' : 'Create User'} </button> {createUser.isError && <p style={{ color: 'red' }}>{createUser.error.message}</p>} </form> ); }

πŸ” How React Query Caching Works

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ React Query Cache: β”‚ β”‚ β”‚ β”‚ Query Key: ['users'] β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Status: success β”‚ β”‚ β”‚ β”‚ Data: [{...}, {...}] β”‚ β”‚ β”‚ β”‚ Last Updated: 5min ago β”‚ β”‚ β”‚ β”‚ Stale: true (>5min) β”‚ β”‚ β”‚ β”‚ Observers: 2 components β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ Query Key: ['user', 42] β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Status: success β”‚ β”‚ β”‚ β”‚ Data: {id:42, name:...} β”‚ β”‚ β”‚ β”‚ Last Updated: 1min ago β”‚ β”‚ β”‚ β”‚ Stale: false (<5min) β”‚ β”‚ β”‚ β”‚ Observers: 1 component β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ When Component A and Component B both call β”‚ β”‚ useQuery(['user', 42]): β”‚ β”‚ - Only ONE network request is made β”‚ β”‚ - Both components share the same cached data β”‚ β”‚ - When data becomes stale, a background refetch β”‚ β”‚ updates both components β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

9. When to Use What β€” Decision Framework

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ "What state management should I use?" β”‚ β”‚ β”‚ β”‚ Is it SERVER data (from API)? β”‚ β”‚ β”œβ”€β”€ YES β†’ React Query / TanStack Query β”‚ β”‚ β”‚ (with caching, refetching, etc.) β”‚ β”‚ └── NO β”‚ β”‚ β”‚ β”‚ β”‚ Is it used by only ONE component? β”‚ β”‚ β”œβ”€β”€ YES β†’ useState / useReducer β”‚ β”‚ └── NO β”‚ β”‚ β”‚ β”‚ β”‚ Is it used by a few nearby components? β”‚ β”‚ β”œβ”€β”€ YES β†’ Lift state up β”‚ β”‚ └── NO β”‚ β”‚ β”‚ β”‚ β”‚ Does it change rarely? β”‚ β”‚ (theme, auth, locale) β”‚ β”‚ β”œβ”€β”€ YES β†’ Context API β”‚ β”‚ └── NO β”‚ β”‚ β”‚ β”‚ β”‚ Is the app small/medium? β”‚ β”‚ β”œβ”€β”€ YES β†’ Zustand β”‚ β”‚ └── NO β”‚ β”‚ β”‚ β”‚ β”‚ Large team/enterprise? β”‚ β”‚ β”œβ”€β”€ YES β†’ Redux Toolkit β”‚ β”‚ └── NO β†’ Zustand β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ“Š Comparison Summary

FeatureuseStateContextZustandRedux TKReact Query
ScopeLocalSubtreeGlobalGlobalServer data
BoilerplateNoneLowLowMediumLow
DevToolsReact DevToolsReact DevToolsRedux DevToolsRedux DevToolsRQ DevTools
AsyncManualManualBuilt-increateAsyncThunkBuilt-in
CachingNoNoNoNoYes βœ…
LearningEasyEasyEasyMediumEasy-Medium
Best forComponent stateApp-wide configUI stateComplex clientAPI data

10. Common Mistakes & Gotchas

Mistake 1: Putting Server State in Redux

// ❌ Don't store API data in Redux const usersSlice = createSlice({ name: 'users', initialState: { data: [], loading: false, error: null }, // ... lots of boilerplate for fetching, caching, invalidation // You're reinventing React Query! }); // βœ… Use React Query for server state const { data: users } = useQuery({ queryKey: ['users'], queryFn: fetchUsers }); // Automatic caching, refetching, deduplication, retry...

Mistake 2: Creating New Object Reference in Context Value

// ❌ New object every render = all consumers re-render function Provider({ children }) { const [user, setUser] = useState(null); return ( <UserContext.Provider value={{ user, setUser }}> {children} </UserContext.Provider> ); } // βœ… Memoize the value function Provider({ children }) { const [user, setUser] = useState(null); const value = useMemo(() => ({ user, setUser }), [user]); return <UserContext.Provider value={value}>{children}</UserContext.Provider>; }

Mistake 3: Over-subscribing to Redux Store

// ❌ Selecting the entire state β€” re-renders on ANY state change const state = useSelector(state => state); // ❌ Creating new object β€” re-renders every time const data = useSelector(state => ({ count: state.counter.count, user: state.user.name, })); // βœ… Select only what you need β€” individual selectors const count = useSelector(state => state.counter.count); const userName = useSelector(state => state.user.name);

11. Practice Problems

πŸ§ͺ Problem 1: Build a Shopping Cart with Redux Toolkit

Build a shopping cart with add, remove, update quantity, and clear actions.

<details> <summary>βœ… Solution</summary>
// cartSlice.js import { createSlice } from '@reduxjs/toolkit'; const cartSlice = createSlice({ name: 'cart', initialState: { items: [], total: 0 }, reducers: { addItem(state, action) { const existing = state.items.find(i => i.id === action.payload.id); if (existing) { existing.quantity += 1; } else { state.items.push({ ...action.payload, quantity: 1 }); } state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0); }, removeItem(state, action) { state.items = state.items.filter(i => i.id !== action.payload); state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0); }, updateQuantity(state, action) { const { id, quantity } = action.payload; const item = state.items.find(i => i.id === id); if (item) { item.quantity = Math.max(0, quantity); if (item.quantity === 0) { state.items = state.items.filter(i => i.id !== id); } } state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0); }, clearCart(state) { state.items = []; state.total = 0; }, }, }); export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions; export const selectCartItems = (state) => state.cart.items; export const selectCartTotal = (state) => state.cart.total; export const selectCartCount = (state) => state.cart.items.reduce((sum, i) => sum + i.quantity, 0); export default cartSlice.reducer;
</details>

πŸ§ͺ Problem 2: Build a Zustand Store for a Todo App

<details> <summary>βœ… Solution</summary>
import { create } from 'zustand'; import { persist } from 'zustand/middleware'; const useTodoStore = create( persist( (set, get) => ({ todos: [], filter: 'all', // 'all' | 'active' | 'completed' addTodo: (text) => set((state) => ({ todos: [...state.todos, { id: Date.now(), text, completed: false, createdAt: new Date().toISOString(), }], })), toggleTodo: (id) => set((state) => ({ todos: state.todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t ), })), deleteTodo: (id) => set((state) => ({ todos: state.todos.filter(t => t.id !== id), })), clearCompleted: () => set((state) => ({ todos: state.todos.filter(t => !t.completed), })), setFilter: (filter) => set({ filter }), get filteredTodos() { const { todos, filter } = get(); switch (filter) { case 'active': return todos.filter(t => !t.completed); case 'completed': return todos.filter(t => t.completed); default: return todos; } }, get activeCount() { return get().todos.filter(t => !t.completed).length; }, }), { name: 'todo-storage' } ) ); export default useTodoStore;
</details>

12. Interview Questions

❓ Q1: Explain the Redux data flow.

Answer:

  1. User interaction triggers an event (click, form submit)
  2. Component dispatches an action β€” a plain object with type and optional payload
  3. The store passes current state and action to the reducer
  4. The reducer (pure function) returns a new state based on the action type
  5. The store updates and notifies all subscribed components
  6. Components re-render with the new state

Key principles: single store, state is read-only (immutable updates), reducers are pure functions.


❓ Q2: What is Redux middleware? Give examples.

Answer: Middleware provides a third-party extension point between dispatching an action and the moment it reaches the reducer. It's used for:

  • redux-thunk: Handle async actions (dispatch functions instead of objects)
  • redux-saga: Complex async flows using generator functions
  • redux-logger: Log actions and state changes
  • redux-persist: Save state to localStorage/AsyncStorage

Middleware intercepts actions and can: log them, transform them, delay them, cancel them, or dispatch additional actions.


❓ Q3: When would you use Context API vs Redux?

Answer:

Context API: For data that changes rarely and is needed by many components β€” theme, auth, locale. It's simple but triggers re-renders of ALL consumers when ANY value changes.

Redux: For complex client state with many update patterns, middleware needs, or when you want time-travel debugging. Better performance with useSelector (only re-renders when selected data changes).

Neither: For server data, use React Query. For local component state, use useState/useReducer.


❓ Q4: What is React Query? Why not just fetch in useEffect?

Answer: React Query (TanStack Query) is a server state management library. Raw useEffect fetching lacks:

  • Caching β€” same data fetched multiple times
  • Deduplication β€” multiple components trigger same request
  • Background refetching β€” data goes stale
  • Retry logic β€” no automatic retry on failure
  • Garbage collection β€” unused cache not cleaned up
  • Optimistic updates β€” UI doesn't respond instantly
  • Pagination/infinite scroll β€” complex to implement

React Query provides all of these out of the box with minimal code.


❓ Q5: What are Redux selectors? Why use them?

Answer: Selectors are functions that extract specific data from the Redux store:

// Simple selector const selectUserName = (state) => state.user.name; // Derived selector (with Reselect for memoization) const selectCompletedTodos = createSelector( selectTodos, (todos) => todos.filter(t => t.completed) );

Benefits: encapsulate state shape (components don't know store structure), memoize expensive computations, prevent unnecessary re-renders.


13. "What's the Output?" Challenges

Challenge 1

const useStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), })); function Counter() { const count = useStore((state) => state.count); console.log('Counter rendered'); return <span>{count}</span>; } function IncrementButton() { const increment = useStore((state) => state.increment); console.log('Button rendered'); return <button onClick={increment}>+</button>; } // After clicking the button once, what logs?
<details> <summary>Answer</summary>
Counter rendered

Only Counter re-renders! IncrementButton subscribes only to state.increment (a function reference that never changes), so it doesn't re-render when count changes. This is Zustand's key advantage β€” fine-grained subscriptions.

</details>

🎯 Module 04 Summary

You now understand:

  • βœ… Different types of state and where each belongs
  • βœ… Context API β€” usage, limitations, and performance optimization
  • βœ… Redux β€” core concepts, data flow, middleware
  • βœ… Redux Toolkit β€” slices, createAsyncThunk, Immer
  • βœ… Zustand β€” lightweight alternative to Redux
  • βœ… React Query β€” server state management
  • βœ… Decision framework β€” when to use what

Next up: Module 05: Performance & React Internals β€” the "secret weapon" module that separates junior from senior developers. β†’