Introduction to Redux
Redux is a predictable state container for JavaScript applications. While often used with React, it's actually framework-agnostic and can be used with any UI library or even vanilla JavaScript.
🏛️ The Banking System Analogy
Think of Redux as a bank:
- Store: The bank vault holding all the money (state)
- Actions: Deposit or withdrawal slips
- Reducers: Bank tellers who process transactions
- Dispatch: Submitting your slip to the teller
- Subscribers: Account holders who get notified of changes
Just like you can't directly access the vault, you can't directly modify the Redux store. You must go through the proper channels (actions and reducers).
Why Redux?
Problems Redux Solves
- Prop Drilling: Passing data through multiple component layers
- State Synchronization: Keeping multiple components in sync
- Predictability: Understanding how state changes over time
- Debugging: Time-travel debugging and state inspection
- Testing: Pure functions are easier to test
Three Principles of Redux
1. Single Source of Truth
The state of your whole application is stored in an object tree within a single store.
// Single state tree
{
user: {
name: 'John Doe',
email: 'john@example.com',
preferences: {
theme: 'dark',
notifications: true
}
},
posts: [
{ id: 1, title: 'Redux is Great', likes: 5 },
{ id: 2, title: 'Learn Redux', likes: 3 }
],
ui: {
isLoading: false,
error: null
}
}
2. State is Read-Only
The only way to change the state is to emit an action, an object describing what happened.
// You cannot do this:
store.state.user.name = 'Jane Doe'; // ❌ Wrong!
// Instead, dispatch an action:
store.dispatch({
type: 'user/updateName',
payload: 'Jane Doe'
}); // ✅ Correct!
3. Changes are Made with Pure Functions
To specify how the state tree is transformed by actions, you write pure reducers.
// Pure function: same input = same output, no side effects
function counterReducer(state = 0, action) {
switch (action.type) {
case 'counter/increment':
return state + 1;
case 'counter/decrement':
return state - 1;
default:
return state;
}
}
// Not pure - modifies state directly ❌
function badReducer(state, action) {
if (action.type === 'increment') {
state.count++; // Mutating state!
return state;
}
return state;
}
Redux Data Flow
The Redux Lifecycle
- Trigger: User interaction or other event occurs
- Dispatch: An action is dispatched to the store
- Reduce: Reducers process the action and return new state
- Update: Store notifies subscribers of the state change
- Render: UI components re-render with new state
// Complete Redux flow example
import { createStore } from 'redux';
// 1. Initial state
const initialState = {
count: 0,
lastAction: null
};
// 2. Reducer function
function counterReducer(state = initialState, action) {
switch (action.type) {
case 'counter/increment':
return {
...state,
count: state.count + 1,
lastAction: 'increment'
};
case 'counter/decrement':
return {
...state,
count: state.count - 1,
lastAction: 'decrement'
};
case 'counter/reset':
return {
...state,
count: 0,
lastAction: 'reset'
};
default:
return state;
}
}
// 3. Create store
const store = createStore(counterReducer);
// 4. Subscribe to changes
store.subscribe(() => {
console.log('State changed:', store.getState());
});
// 5. Dispatch actions
store.dispatch({ type: 'counter/increment' });
// Console: State changed: { count: 1, lastAction: 'increment' }
store.dispatch({ type: 'counter/increment' });
// Console: State changed: { count: 2, lastAction: 'increment' }
store.dispatch({ type: 'counter/decrement' });
// Console: State changed: { count: 1, lastAction: 'decrement' }
Core Concepts Deep Dive
The Store
The store is the object that brings actions and reducers together. It has several responsibilities:
// Creating a store
import { createStore } from 'redux';
const store = createStore(reducer, initialState);
// Store API methods
store.getState(); // Get current state
store.dispatch(action); // Dispatch an action
store.subscribe(listener); // Subscribe to changes
store.replaceReducer(newReducer); // Hot reload reducers
// Real-world example with middleware
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
const store = createStore(
rootReducer,
initialState,
applyMiddleware(thunk, logger)
);
Actions
Actions are plain JavaScript objects that represent an intention to change the state.
// Simple action
const incrementAction = {
type: 'counter/increment'
};
// Action with payload
const addTodoAction = {
type: 'todos/add',
payload: {
id: 1,
text: 'Learn Redux',
completed: false
}
};
// Action with error
const fetchFailedAction = {
type: 'data/fetchFailed',
payload: new Error('Network error'),
error: true
};
// Action with meta information
const trackingAction = {
type: 'user/login',
payload: { userId: '123' },
meta: {
analytics: {
event: 'user_login',
timestamp: Date.now()
}
}
};
Reducers
Reducers specify how the application's state changes in response to actions.
// Simple reducer
function visibilityReducer(state = 'SHOW_ALL', action) {
switch (action.type) {
case 'SET_VISIBILITY_FILTER':
return action.payload;
default:
return state;
}
}
// Complex reducer with nested state
function todosReducer(state = [], action) {
switch (action.type) {
case 'todos/add':
return [...state, action.payload];
case 'todos/toggle':
return state.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
);
case 'todos/remove':
return state.filter(todo => todo.id !== action.payload);
default:
return state;
}
}
// Combining reducers
import { combineReducers } from 'redux';
const rootReducer = combineReducers({
todos: todosReducer,
visibility: visibilityReducer,
user: userReducer
});
// State shape with combined reducers
{
todos: [...],
visibility: 'SHOW_ALL',
user: { ... }
}
Real-World Example: Shopping Cart
// Shopping cart Redux implementation
// 1. Define initial state
const initialState = {
items: [],
total: 0,
discount: 0,
loading: false,
error: null
};
// 2. Define action types
const ADD_TO_CART = 'cart/addItem';
const REMOVE_FROM_CART = 'cart/removeItem';
const UPDATE_QUANTITY = 'cart/updateQuantity';
const APPLY_DISCOUNT = 'cart/applyDiscount';
const CHECKOUT_START = 'cart/checkoutStart';
const CHECKOUT_SUCCESS = 'cart/checkoutSuccess';
const CHECKOUT_FAILURE = 'cart/checkoutFailure';
// 3. Create reducer
function cartReducer(state = initialState, action) {
switch (action.type) {
case ADD_TO_CART: {
const { product } = action.payload;
const existingItem = state.items.find(item => item.id === product.id);
if (existingItem) {
return {
...state,
items: state.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
),
total: state.total + product.price
};
}
return {
...state,
items: [...state.items, { ...product, quantity: 1 }],
total: state.total + product.price
};
}
case REMOVE_FROM_CART: {
const item = state.items.find(item => item.id === action.payload);
return {
...state,
items: state.items.filter(item => item.id !== action.payload),
total: state.total - (item.price * item.quantity)
};
}
case UPDATE_QUANTITY: {
const { id, quantity } = action.payload;
const item = state.items.find(item => item.id === id);
const quantityDiff = quantity - item.quantity;
return {
...state,
items: state.items.map(item =>
item.id === id
? { ...item, quantity }
: item
),
total: state.total + (item.price * quantityDiff)
};
}
case APPLY_DISCOUNT: {
return {
...state,
discount: action.payload
};
}
case CHECKOUT_START:
return {
...state,
loading: true,
error: null
};
case CHECKOUT_SUCCESS:
return {
...initialState, // Reset cart after successful checkout
loading: false
};
case CHECKOUT_FAILURE:
return {
...state,
loading: false,
error: action.payload
};
default:
return state;
}
}
// 4. Create action creators
const addToCart = (product) => ({
type: ADD_TO_CART,
payload: { product }
});
const removeFromCart = (productId) => ({
type: REMOVE_FROM_CART,
payload: productId
});
const updateQuantity = (id, quantity) => ({
type: UPDATE_QUANTITY,
payload: { id, quantity }
});
const applyDiscount = (discountPercent) => ({
type: APPLY_DISCOUNT,
payload: discountPercent
});
// 5. Usage in application
const store = createStore(cartReducer);
// Add item to cart
store.dispatch(addToCart({
id: 1,
name: 'Redux Book',
price: 29.99
}));
// Update quantity
store.dispatch(updateQuantity(1, 3));
// Apply discount
store.dispatch(applyDiscount(10)); // 10% off
// Get current state
console.log(store.getState());
// {
// items: [{ id: 1, name: 'Redux Book', price: 29.99, quantity: 3 }],
// total: 89.97,
// discount: 10,
// loading: false,
// error: null
// }
Common Patterns and Best Practices
State Shape Design
- Keep state flat and normalized
- Store IDs instead of nested objects
- Separate UI state from data state
- Avoid duplicating data
// Bad: Nested and duplicated data
{
posts: [
{
id: 1,
title: 'Post 1',
author: { id: 1, name: 'John' },
comments: [
{ id: 1, text: 'Great post!', author: { id: 2, name: 'Jane' } }
]
}
]
}
// Good: Normalized data
{
entities: {
posts: {
1: { id: 1, title: 'Post 1', authorId: 1, commentIds: [1] }
},
users: {
1: { id: 1, name: 'John' },
2: { id: 2, name: 'Jane' }
},
comments: {
1: { id: 1, text: 'Great post!', authorId: 2, postId: 1 }
}
},
ui: {
currentPostId: 1
}
}
Action Naming Conventions
- Use domain/eventName format
- Be descriptive and specific
- Use past tense for events that happened
- Use present tense for commands
// Good action type names
'user/loggedIn'
'cart/itemAdded'
'data/fetchRequested'
'data/fetchSucceeded'
'data/fetchFailed'
// Bad action type names
'UPDATE'
'CHANGE_STATE'
'SET_DATA'
Debugging Redux Applications
// Redux DevTools integration
const store = createStore(
rootReducer,
window.__REDUX_DEVTOOLS_EXTENSION__ &&
window.__REDUX_DEVTOOLS_EXTENSION__()
);
// Logging middleware
const logger = store => next => action => {
console.group(action.type);
console.info('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
console.groupEnd();
return result;
};
// Error tracking
const crashReporter = store => next => action => {
try {
return next(action);
} catch (err) {
console.error('Caught an exception!', err);
// Send to error tracking service
errorTracker.report(err, {
state: store.getState(),
action
});
throw err;
}
};
Practice Exercise
Task: Create a Todo List Redux Store
Implement a Redux store for a todo list application with the following features:
- Add, remove, and toggle todos
- Filter todos (all, active, completed)
- Clear completed todos
// Starter template
const initialState = {
todos: [],
filter: 'all' // 'all', 'active', 'completed'
};
// TODO: Implement the reducer
function todoReducer(state = initialState, action) {
// Your code here
}
// TODO: Create action creators
const addTodo = (text) => { /* ... */ };
const toggleTodo = (id) => { /* ... */ };
const setFilter = (filter) => { /* ... */ };
// TODO: Create selector functions
const getVisibleTodos = (state) => { /* ... */ };