Learn Redux in 10 DaysDay 1: Why Redux?
Chapter 1Learn Redux in 10 Days

Day 1: Why Redux?

What You'll Learn Today

  • Understand state management challenges in React
  • Identify the Prop Drilling problem and its impact
  • Learn the benefits and limitations of Context API
  • Understand the problems Redux solves
  • Learn the three principles of Redux
  • Know why Redux Toolkit is the current standard

State Management Challenges in React

When you start building a React application, useState feels like all you need. But as your application grows, state management quickly becomes complex.

Scattered useState

In small applications, it's natural for each component to manage its own state.

function ProductPage() {
  const [product, setProduct] = useState(null);
  const [cart, setCart] = useState([]);
  const [user, setUser] = useState(null);
  const [notifications, setNotifications] = useState([]);
  const [theme, setTheme] = useState('light');
  const [language, setLanguage] = useState('en');

  // Now you need to pass these down to child components...
}
TypeScript version
interface Product {
  id: string;
  name: string;
  price: number;
}

interface CartItem {
  product: Product;
  quantity: number;
}

interface User {
  id: string;
  name: string;
  email: string;
}

interface Notification {
  id: string;
  message: string;
  type: 'info' | 'warning' | 'error';
}

function ProductPage() {
  const [product, setProduct] = useState<Product | null>(null);
  const [cart, setCart] = useState<CartItem[]>([]);
  const [user, setUser] = useState<User | null>(null);
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const [language, setLanguage] = useState<string>('en');
}

The trouble begins when multiple components need access to the same state.


The Prop Drilling Problem

Prop Drilling is the pattern where intermediate components receive and pass down props they don't use themselves, just so a deeply nested child can access the data.

Concrete Example: E-Commerce Component Tree

Consider an e-commerce site where user info and cart data need to be displayed in multiple places.

flowchart TB
    App["App<br/>(user, cart)"]
    App --> Header["Header<br/>(user, cart)"]
    App --> Main["Main<br/>(user, cart)"]
    App --> Footer["Footer"]

    Header --> Logo["Logo"]
    Header --> Nav["Navigation<br/>(user, cart)"]
    Nav --> UserMenu["UserMenu<br/>(user)"]
    Nav --> CartIcon["CartIcon<br/>(cart)"]

    Main --> ProductList["ProductList<br/>(cart)"]
    Main --> Sidebar["Sidebar<br/>(user)"]
    ProductList --> ProductCard["ProductCard<br/>(cart)"]
    ProductCard --> AddToCartBtn["AddToCartButton<br/>(cart)"]

    Sidebar --> UserProfile["UserProfile<br/>(user)"]
    Sidebar --> Recommendations["Recommendations<br/>(user)"]

    style App fill:#ef4444,color:#fff
    style Header fill:#f59e0b,color:#fff
    style Nav fill:#f59e0b,color:#fff
    style Main fill:#f59e0b,color:#fff
    style ProductList fill:#f59e0b,color:#fff
    style ProductCard fill:#f59e0b,color:#fff
    style UserMenu fill:#22c55e,color:#fff
    style CartIcon fill:#22c55e,color:#fff
    style AddToCartBtn fill:#22c55e,color:#fff
    style UserProfile fill:#22c55e,color:#fff
    style Recommendations fill:#22c55e,color:#fff

Green components actually use the data. Yellow components don't use the data themselves -- they just pass it through to their children.

Prop Drilling in Code

// Level 0: App - where state is defined
function App() {
  const [user, setUser] = useState({ name: 'John Doe', email: 'john@example.com' });
  const [cart, setCart] = useState([]);

  const addToCart = (product) => {
    setCart(prev => [...prev, product]);
  };

  return (
    <div>
      <Header user={user} cart={cart} />
      <Main user={user} cart={cart} addToCart={addToCart} />
      <Footer />
    </div>
  );
}

// Level 1: Header - doesn't use user or cart, just passes them through
function Header({ user, cart }) {
  return (
    <header>
      <Logo />
      <Navigation user={user} cart={cart} />
    </header>
  );
}

// Level 2: Navigation - doesn't use user or cart, just passes them through
function Navigation({ user, cart }) {
  return (
    <nav>
      <UserMenu user={user} />
      <CartIcon cart={cart} />
    </nav>
  );
}

// Level 3: UserMenu - finally uses user!
function UserMenu({ user }) {
  return <span>Hello, {user.name}</span>;
}

// Level 3: CartIcon - finally uses cart!
function CartIcon({ cart }) {
  return <span>Cart ({cart.length})</span>;
}

// Level 1: Main - doesn't use user, cart, or addToCart, just passes them through
function Main({ user, cart, addToCart }) {
  return (
    <main>
      <ProductList cart={cart} addToCart={addToCart} />
      <Sidebar user={user} />
    </main>
  );
}

// Level 2: ProductList - doesn't use cart or addToCart, just passes them through
function ProductList({ cart, addToCart }) {
  const products = [{ id: 1, name: 'T-Shirt', price: 20 }];
  return (
    <div>
      {products.map(p => (
        <ProductCard key={p.id} product={p} cart={cart} addToCart={addToCart} />
      ))}
    </div>
  );
}

// Level 3: ProductCard - passes addToCart through
function ProductCard({ product, cart, addToCart }) {
  const isInCart = cart.some(item => item.id === product.id);
  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <AddToCartButton product={product} addToCart={addToCart} isInCart={isInCart} />
    </div>
  );
}

// Level 4: AddToCartButton - finally uses addToCart!
function AddToCartButton({ product, addToCart, isInCart }) {
  return (
    <button onClick={() => addToCart(product)} disabled={isInCart}>
      {isInCart ? 'Already in cart' : 'Add to cart'}
    </button>
  );
}
TypeScript version
interface User {
  name: string;
  email: string;
}

interface Product {
  id: number;
  name: string;
  price: number;
}

function App() {
  const [user, setUser] = useState<User>({ name: 'John Doe', email: 'john@example.com' });
  const [cart, setCart] = useState<Product[]>([]);

  const addToCart = (product: Product): void => {
    setCart(prev => [...prev, product]);
  };

  return (
    <div>
      <Header user={user} cart={cart} />
      <Main user={user} cart={cart} addToCart={addToCart} />
      <Footer />
    </div>
  );
}

function Header({ user, cart }: { user: User; cart: Product[] }) {
  return (
    <header>
      <Logo />
      <Navigation user={user} cart={cart} />
    </header>
  );
}

function Navigation({ user, cart }: { user: User; cart: Product[] }) {
  return (
    <nav>
      <UserMenu user={user} />
      <CartIcon cart={cart} />
    </nav>
  );
}

function UserMenu({ user }: { user: User }) {
  return <span>Hello, {user.name}</span>;
}

function CartIcon({ cart }: { cart: Product[] }) {
  return <span>Cart ({cart.length})</span>;
}

function Main({ user, cart, addToCart }: { user: User; cart: Product[]; addToCart: (p: Product) => void }) {
  return (
    <main>
      <ProductList cart={cart} addToCart={addToCart} />
      <Sidebar user={user} />
    </main>
  );
}

function ProductList({ cart, addToCart }: { cart: Product[]; addToCart: (p: Product) => void }) {
  const products: Product[] = [{ id: 1, name: 'T-Shirt', price: 20 }];
  return (
    <div>
      {products.map(p => (
        <ProductCard key={p.id} product={p} cart={cart} addToCart={addToCart} />
      ))}
    </div>
  );
}

function ProductCard({ product, cart, addToCart }: { product: Product; cart: Product[]; addToCart: (p: Product) => void }) {
  const isInCart = cart.some(item => item.id === product.id);
  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <AddToCartButton product={product} addToCart={addToCart} isInCart={isInCart} />
    </div>
  );
}

function AddToCartButton({ product, addToCart, isInCart }: { product: Product; addToCart: (p: Product) => void; isInCart: boolean }) {
  return (
    <button onClick={() => addToCart(product)} disabled={isInCart}>
      {isInCart ? 'Already in cart' : 'Add to cart'}
    </button>
  );
}

Why Prop Drilling Is Problematic

  1. Reduced readability: Intermediate components are cluttered with props they don't use
  2. Poor maintainability: Changing the shape of state requires updating every component in the chain
  3. Difficult refactoring: Reorganizing the component tree breaks the prop flow
  4. Complex testing: You must mock irrelevant props when testing intermediate components

Context API: A Partial Solution

The Context API, introduced in React 16.3, is React's built-in mechanism for avoiding prop drilling.

import { createContext, useContext, useState } from 'react';

// Create Contexts
const UserContext = createContext(null);
const CartContext = createContext(null);

// Provider
function App() {
  const [user, setUser] = useState({ name: 'John Doe' });
  const [cart, setCart] = useState([]);
  const addToCart = (product) => setCart(prev => [...prev, product]);

  return (
    <UserContext.Provider value={user}>
      <CartContext.Provider value={{ cart, addToCart }}>
        <Header />
        <Main />
        <Footer />
      </CartContext.Provider>
    </UserContext.Provider>
  );
}

// Access data directly from any level
function UserMenu() {
  const user = useContext(UserContext);
  return <span>Hello, {user.name}</span>;
}

function CartIcon() {
  const { cart } = useContext(CartContext);
  return <span>Cart ({cart.length})</span>;
}

function AddToCartButton({ product }) {
  const { cart, addToCart } = useContext(CartContext);
  const isInCart = cart.some(item => item.id === product.id);
  return (
    <button onClick={() => addToCart(product)} disabled={isInCart}>
      {isInCart ? 'Already in cart' : 'Add to cart'}
    </button>
  );
}

Limitations of Context API

Context API solves prop drilling, but it has several significant limitations.

1. Re-rendering Problem

When a Context value changes, every component that consumes that Context re-renders.

// In this example, when cart changes, CartIcon AND AddToCartButton
// AND every other consumer all re-render
const CartContext = createContext(null);

function CartProvider({ children }) {
  const [cart, setCart] = useState([]);
  const [totalPrice, setTotalPrice] = useState(0);

  // Changing either cart or totalPrice causes ALL
  // CartContext consumers to re-render
  return (
    <CartContext.Provider value={{ cart, totalPrice, setCart, setTotalPrice }}>
      {children}
    </CartContext.Provider>
  );
}

2. Provider Nesting Hell

As application state grows, Providers become deeply nested.

function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <CartProvider>
          <NotificationProvider>
            <LanguageProvider>
              <ModalProvider>
                <Content />
              </ModalProvider>
            </LanguageProvider>
          </NotificationProvider>
        </CartProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

3. Debugging Difficulty

Context API has no mechanism for tracking state change history. It's hard to determine "when and why the state changed."

4. No Middleware

There's no built-in way to intercept state changes for cross-cutting concerns like API calls, logging, or state persistence.


What Redux Solves

Redux provides a systematic solution to all of the problems described above.

Key Benefits of Redux

flowchart LR
    subgraph Problems["Problems"]
        P1["Prop Drilling"]
        P2["Re-renders"]
        P3["Hard to Debug"]
        P4["Async Logic"]
    end

    subgraph Solutions["Redux Solutions"]
        S1["Global Store"]
        S2["Selector Optimization"]
        S3["DevTools"]
        S4["Middleware"]
    end

    P1 --> S1
    P2 --> S2
    P3 --> S3
    P4 --> S4

    style Problems fill:#ef4444,color:#fff
    style Solutions fill:#22c55e,color:#fff
  1. Single Source of Truth: The entire application state lives in one Store
  2. Predictable State Updates: State changes always go through Actions and Reducers
  3. Powerful DevTools: State change history, time-travel debugging, state export/import
  4. Middleware: Declaratively integrate API calls, logging, state persistence
  5. Performance Optimization: Fine-grained subscriptions via useSelector prevent unnecessary re-renders

The Three Principles of Redux

Redux is built on three simple principles.

Principle 1: Single Source of Truth

The entire application state is stored in a single Store object tree.

// The entire application state is consolidated in one object
const store = {
  user: {
    name: 'John Doe',
    email: 'john@example.com',
    isLoggedIn: true
  },
  cart: {
    items: [
      { id: 1, name: 'T-Shirt', price: 20, quantity: 1 }
    ],
    totalPrice: 20
  },
  notifications: [
    { id: 1, message: 'Order confirmed', type: 'success' }
  ],
  ui: {
    theme: 'light',
    language: 'en',
    sidebarOpen: false
  }
};

Principle 2: State Is Read-Only

The only way to change state is to dispatch an Action. You cannot modify state directly.

// BAD: Mutating state directly
store.cart.items.push(newItem);

// GOOD: Dispatch an Action
store.dispatch({
  type: 'cart/addItem',
  payload: { id: 2, name: 'Hoodie', price: 50 }
});

Principle 3: Changes Are Made with Pure Functions

State changes are performed by pure functions called Reducers. A Reducer receives the current state and an Action, and returns a new state.

// A Reducer is a pure function
// The same inputs always produce the same output
function cartReducer(state = { items: [], totalPrice: 0 }, action) {
  switch (action.type) {
    case 'cart/addItem':
      const newItems = [...state.items, action.payload];
      const newTotal = newItems.reduce((sum, item) => sum + item.price, 0);
      return {
        ...state,
        items: newItems,
        totalPrice: newTotal
      };
    case 'cart/removeItem':
      const filteredItems = state.items.filter(item => item.id !== action.payload);
      const filteredTotal = filteredItems.reduce((sum, item) => sum + item.price, 0);
      return {
        ...state,
        items: filteredItems,
        totalPrice: filteredTotal
      };
    default:
      return state;
  }
}

Redux Data Flow

Data in Redux always flows in one direction. This predictable flow makes debugging and understanding the application much easier.

flowchart LR
    UI["UI<br/>User Interaction"]
    Action["Action<br/>{type, payload}"]
    Dispatch["Dispatch<br/>store.dispatch()"]
    Middleware["Middleware<br/>(thunk, logger, etc.)"]
    Reducer["Reducer<br/>Pure Function"]
    Store["Store<br/>New State"]
    Render["Re-render<br/>Update UI"]

    UI -->|"Event fires"| Action
    Action -->|"Send"| Dispatch
    Dispatch -->|"Pass through"| Middleware
    Middleware -->|"After processing"| Reducer
    Reducer -->|"Returns new state"| Store
    Store -->|"Notifies subscribers"| Render
    Render -->|"Display"| UI

    style UI fill:#3b82f6,color:#fff
    style Action fill:#f59e0b,color:#fff
    style Dispatch fill:#f59e0b,color:#fff
    style Middleware fill:#8b5cf6,color:#fff
    style Reducer fill:#22c55e,color:#fff
    style Store fill:#22c55e,color:#fff
    style Render fill:#3b82f6,color:#fff
  1. The user interacts with the UI (e.g., clicks a button)
  2. An Action object is created ({ type: 'cart/addItem', payload: item })
  3. The Action is sent to the Store via dispatch()
  4. Middleware processes the Action (logging, API calls, etc.)
  5. The Reducer computes a new state based on the Action
  6. The Store is updated with the new state
  7. The UI re-renders to reflect the new state

Legacy Redux vs Redux Toolkit

Problems with Legacy Redux

When Redux was first released in 2015, it required a large amount of boilerplate code.

// --- Legacy Redux ---

// Action Types (constant definitions)
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
const DELETE_TODO = 'DELETE_TODO';

// Action Creators
function addTodo(text) {
  return {
    type: ADD_TODO,
    payload: {
      id: Date.now(),
      text,
      completed: false
    }
  };
}

function toggleTodo(id) {
  return {
    type: TOGGLE_TODO,
    payload: id
  };
}

function deleteTodo(id) {
  return {
    type: DELETE_TODO,
    payload: id
  };
}

// Reducer (manually writing immutable updates)
function todosReducer(state = [], action) {
  switch (action.type) {
    case ADD_TODO:
      return [...state, action.payload];
    case TOGGLE_TODO:
      return state.map(todo =>
        todo.id === action.payload
          ? { ...todo, completed: !todo.completed }
          : todo
      );
    case DELETE_TODO:
      return state.filter(todo => todo.id !== action.payload);
    default:
      return state;
  }
}

// Store (manually composing middleware)
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

const rootReducer = combineReducers({
  todos: todosReducer,
});

const store = createStore(rootReducer, applyMiddleware(thunk));

Redux Toolkit (RTK) -- The Current Standard

Redux Toolkit was released in 2019 and is the officially recommended approach. The code above becomes dramatically simpler.

// --- Redux Toolkit ---
import { createSlice, configureStore } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo(state, action) {
      // Immer lets you write "mutating" syntax (actually immutable under the hood)
      state.push({
        id: Date.now(),
        text: action.payload,
        completed: false
      });
    },
    toggleTodo(state, action) {
      const todo = state.find(t => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    deleteTodo(state, action) {
      return state.filter(todo => todo.id !== action.payload);
    }
  }
});

// Action Creators are auto-generated
export const { addTodo, toggleTodo, deleteTodo } = todosSlice.actions;

// Store creation (DevTools and thunk are configured automatically)
const store = configureStore({
  reducer: {
    todos: todosSlice.reducer,
  }
});
TypeScript version
import { createSlice, configureStore, PayloadAction } from '@reduxjs/toolkit';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

const todosSlice = createSlice({
  name: 'todos',
  initialState: [] as Todo[],
  reducers: {
    addTodo(state, action: PayloadAction<string>) {
      state.push({
        id: Date.now(),
        text: action.payload,
        completed: false
      });
    },
    toggleTodo(state, action: PayloadAction<number>) {
      const todo = state.find(t => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    deleteTodo(state, action: PayloadAction<number>) {
      return state.filter(todo => todo.id !== action.payload);
    }
  }
});

export const { addTodo, toggleTodo, deleteTodo } = todosSlice.actions;

const store = configureStore({
  reducer: {
    todos: todosSlice.reducer,
  }
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

State Management Comparison

Feature useState / props Context API Redux (RTK)
Learning curve Low Low Moderate
Setup required None Minimal Some
Solves prop drilling No Yes Yes
Performance optimization Manual (memo, etc.) Difficult Automatic via useSelector
Debugging tools React DevTools React DevTools Redux DevTools
Middleware None None Yes (thunk, etc.)
Time-travel debugging No No Yes
Async handling useEffect useEffect createAsyncThunk
State persistence Manual Manual redux-persist
Testability Component-coupled Component-coupled Logic can be isolated
Best for app size Small Small to medium Medium to large

When Should You Use Redux?

Redux Is a Good Fit When

  • Multiple components share the same state: User authentication, shopping cart, etc.
  • State update logic is complex: Many conditionals, derived calculations
  • State change tracking is needed: Debugging and logging are critical in business apps
  • Server data caching: Combined with RTK Query
  • Team development: Enforces a consistent state management pattern

Redux Is Unnecessary When

  • Simple apps: One form, a few pages
  • Only server state management is needed: TanStack Query (React Query) is sufficient
  • Only local state: useState or useReducer handles it fine
  • Prototypes / MVPs: Speed is the priority

Summary

Concept Description
Prop Drilling The problem of intermediate components passing unused props down the tree
Context API Solves prop drilling but has re-rendering and debugging limitations
Redux Predictable state management library with single Store, read-only state, pure Reducers
Redux Toolkit (RTK) Official Redux toolset that dramatically reduces boilerplate
Single Source of Truth The principle of managing all app state in one Store
Unidirectional Data Flow Action -> Dispatch -> Reducer -> Store -> UI

Today we learned the motivation behind Redux -- why it exists. React's built-in features aren't enough for managing state in medium-to-large applications, and Redux provides a systematic solution to those challenges.

Tomorrow in Day 2, we'll start writing actual code with Redux Toolkit.


Exercises

Exercise 1: Identifying Prop Drilling

In the following component structure, identify where prop drilling is occurring.

App (theme, user)
  └── Dashboard (theme, user)
        β”œβ”€β”€ DashboardHeader (theme)
        β”‚     └── ThemeToggle (theme)
        └── DashboardContent (user)
              └── UserCard (user)

Task: The Dashboard component doesn't use theme or user itself. Refactor this using Context API to eliminate the prop drilling.

Exercise 2: Choosing a State Management Approach

For each of the following scenarios, choose the best state management approach (useState / Context API / Redux) and explain your reasoning.

  1. Managing input values in a login form
  2. User authentication data used across 10+ pages
  3. Message list in a real-time chat application
  4. Modal open/close state
  5. E-commerce shopping cart (add/remove items, change quantities, calculate totals)

Exercise 3: The Three Principles

Explain each of the three principles of Redux in your own words and describe why each one is important.