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
- Reduced readability: Intermediate components are cluttered with props they don't use
- Poor maintainability: Changing the shape of state requires updating every component in the chain
- Difficult refactoring: Reorganizing the component tree breaks the prop flow
- 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
- Single Source of Truth: The entire application state lives in one Store
- Predictable State Updates: State changes always go through Actions and Reducers
- Powerful DevTools: State change history, time-travel debugging, state export/import
- Middleware: Declaratively integrate API calls, logging, state persistence
- Performance Optimization: Fine-grained subscriptions via
useSelectorprevent 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
- The user interacts with the UI (e.g., clicks a button)
- An Action object is created (
{ type: 'cart/addItem', payload: item }) - The Action is sent to the Store via
dispatch() - Middleware processes the Action (logging, API calls, etc.)
- The Reducer computes a new state based on the Action
- The Store is updated with the new state
- 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.
- Managing input values in a login form
- User authentication data used across 10+ pages
- Message list in a real-time chat application
- Modal open/close state
- 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.