What is Redux?
Redux is a predictable state container for JavaScript applications. It helps you write applications that behave consistently, run in different environments (client, server, and native), and are easy to test.
Originally designed for React, Redux can be used with any UI layer or framework, including Vue, Angular, or vanilla JavaScript. It's most commonly used with React, where it complements React's component-based architecture.
When to Use Redux
Redux is most beneficial in the following scenarios:
- Your application has a large amount of application state needed in many places
- The app state is updated frequently
- The logic to update that state may be complex
- The app has a medium or large-sized codebase with many developers working on it
- You need to track how state is updated over time
For small applications or simple state needs, alternatives like React's Context API or component-local state might be simpler solutions.
Core Principles of Redux
Redux follows three fundamental principles that shape its architecture and usage:
1. Single Source of Truth
The global state of your application is stored in a single store as a JavaScript object. This makes it easier to debug and inspect the application, enables server-rendering, and simplifies persistence and rehydration.
// Example of a single Redux store
const store = {
users: {
currentUser: {
id: 'u123',
name: 'Alice Johnson',
email: 'alice@example.com',
preferences: {
theme: 'dark',
notifications: true
}
},
isLoading: false,
error: null
},
products: {
items: [
{ id: 'p1', name: 'Laptop', price: 999 },
{ id: 'p2', name: 'Phone', price: 699 }
],
selectedProduct: 'p1',
filter: 'all',
isLoading: false
},
cart: {
items: [
{ productId: 'p2', quantity: 1 }
],
isCheckingOut: false
},
ui: {
menuOpen: false,
darkMode: true,
currentPage: 'home'
}
}
Real-World Analogy: Library Database
Think of Redux's single store like a library's central database. No matter which librarian you ask or which terminal you use, you'll get the same information about book availability, location, and borrowing status. This prevents the confusion that would arise if different terminals showed different information about the same book.
2. State is Read-Only
The only way to change the state is to emit an action, an object describing what happened. This ensures that neither the views nor the network callbacks write directly to the state, making state changes predictable and traceable.
// Actions are plain JavaScript objects with a type property
const addToCartAction = {
type: 'cart/addItem',
payload: {
productId: 'p1',
quantity: 1
}
};
// Dispatching an action is the only way to modify the state
store.dispatch(addToCartAction);
Real-World Analogy: Bank Transactions
Imagine if you could modify your bank account balance directly whenever you wanted. This would lead to chaos! Instead, banks require transaction forms (like deposits or withdrawals) to change your balance. These forms are like Redux actions - they document what change should occur, are processed by authorized personnel (reducers), and leave a clear audit trail.
3. Changes are Made with Pure Functions
To specify how the state tree is transformed by actions, you write pure reducers - functions that take the previous state and an action, and return the next state. Reducers must be pure functions, meaning they don't modify the input state, make API calls, or produce side effects.
// A reducer is a pure function that takes state and an action
// and returns a new state without modifying the original
function cartReducer(state = { items: [] }, action) {
switch (action.type) {
case 'cart/addItem':
return {
...state,
items: [
...state.items,
{
productId: action.payload.productId,
quantity: action.payload.quantity
}
]
};
case 'cart/removeItem':
return {
...state,
items: state.items.filter(item =>
item.productId !== action.payload.productId
)
};
default:
return state;
}
}
Real-World Analogy: Recipe Book
A reducer is like a recipe in a cookbook. Given the same ingredients (previous state and action), it will always produce the same dish (new state). It doesn't change the original ingredients, and it doesn't depend on anything outside the recipe. This predictability means you can make the same meal consistently, time after time.
The Redux Flow
Redux follows a unidirectional data flow that makes state changes predictable and traceable. Understanding this flow is key to mastering Redux.
Here's how the data flows through Redux:
- User Interaction: A user interacts with the application (clicks a button, submits a form, etc.)
- Dispatch Action: The interaction triggers the dispatch of an action (a plain JavaScript object describing what happened)
- Reducer Processing: The store sends the current state and the action to the reducer function
- State Update: The reducer creates a new state based on the old state and the action
- Store Update: The store updates its internal state with the new state
- UI Notification: The store notifies all connected components about the state change
- Re-render: Connected components re-render with the new state
// 1. Define an action type
const ADD_TO_CART = 'cart/addItem';
// 2. Create an action creator
function addToCart(productId, quantity) {
return {
type: ADD_TO_CART,
payload: {
productId,
quantity
}
};
}
// 3. Define a reducer
function cartReducer(state = { items: [] }, action) {
switch (action.type) {
case ADD_TO_CART:
return {
...state,
items: [
...state.items,
{
productId: action.payload.productId,
quantity: action.payload.quantity
}
]
};
default:
return state;
}
}
// 4. Create a store
const store = Redux.createStore(cartReducer);
// 5. Subscribe to state changes
store.subscribe(() => {
console.log('New state:', store.getState());
});
// 6. Dispatch an action
store.dispatch(addToCart('p1', 1));
// Console output:
// New state: { items: [{ productId: 'p1', quantity: 1 }] }
Real-World Analogy: Restaurant Order System
The Redux flow is like a restaurant ordering system:
- Customer (UI): Places an order (dispatches an action)
- Waiter (Dispatch): Takes the order to the kitchen
- Chef (Reducer): Prepares the food according to the order without changing the original ingredients
- Kitchen Manager (Store): Updates the inventory and notifies the waiter
- Waiter (Subscribe): Brings the prepared food back to the customer
- Customer (UI): Receives the food and enjoys it
Just like in a well-run restaurant where orders follow a clear path, Redux ensures that state changes follow a consistent, predictable flow.
Core Components of Redux
Actions
Actions are plain JavaScript objects that represent an intention to change the state. They must have a type property to indicate the type of action being performed. Actions are typically created by functions called action creators.
// Action type constant
const UPDATE_USER = 'user/update';
// Action creator
function updateUser(userData) {
return {
type: UPDATE_USER,
payload: userData
};
}
// Dispatching an action
dispatch(updateUser({
name: 'Alice',
email: 'alice@example.com'
}));
Best practices for actions:
- Use string constants for action types
- Follow a consistent naming convention (e.g., 'domain/eventName')
- Use the Flux Standard Action format (type, payload, error, meta)
- Keep actions as simple as possible
Reducers
Reducers are pure functions that specify how the application's state changes in response to actions. A reducer takes the current state and an action, and returns a new state.
function userReducer(state = initialUserState, action) {
switch (action.type) {
case 'user/update':
return {
...state,
profile: {
...state.profile,
...action.payload
},
lastUpdated: new Date().toISOString()
};
case 'user/logout':
return initialUserState;
default:
return state;
}
}
Key characteristics of reducers:
- They never mutate the state
- They are pure functions (same input = same output)
- They don't perform side effects (API calls, etc.)
- They handle each action type with a case statement
- They return the original state for unrecognized actions
Store
The store is the object that brings actions and reducers together. It holds the application state, allows state to be updated via dispatch(action), and registers listeners via subscribe(listener).
import { createStore } from 'redux';
import rootReducer from './reducers';
const store = createStore(
rootReducer,
// Optional initial state
initialState,
// Optional enhancers like middleware
applyMiddleware(...middleware)
);
// Methods provided by the store:
store.getState(); // Returns the current state
store.dispatch(action); // Dispatches an action
store.subscribe(listener); // Registers a state change listener
Important store concepts:
- There is typically only one store per Redux application
- The store's state can only be changed by dispatching actions
- Reducers specify how actions transform the state
- Middleware can intercept dispatched actions for side effects
Combining Reducers
As applications grow, the state becomes more complex. Redux allows you to split your reducers into separate functions, each managing a slice of the state, and then combine them with combineReducers.
import { combineReducers } from 'redux';
// Reducer for user-related state
function userReducer(state = {}, action) {
// Handle user-related actions
switch (action.type) {
case 'user/login':
case 'user/update':
case 'user/logout':
// Return new user state
default:
return state;
}
}
// Reducer for product-related state
function productReducer(state = { items: [] }, action) {
// Handle product-related actions
switch (action.type) {
case 'products/fetch':
case 'products/filter':
case 'products/select':
// Return new product state
default:
return state;
}
}
// Reducer for cart-related state
function cartReducer(state = { items: [] }, action) {
// Handle cart-related actions
switch (action.type) {
case 'cart/addItem':
case 'cart/removeItem':
case 'cart/updateQuantity':
// Return new cart state
default:
return state;
}
}
// Reducer for UI-related state
function uiReducer(state = {}, action) {
// Handle UI-related actions
switch (action.type) {
case 'ui/toggleMenu':
case 'ui/setTheme':
case 'ui/showModal':
// Return new UI state
default:
return state;
}
}
// Combine all reducers into a single root reducer
const rootReducer = combineReducers({
user: userReducer,
products: productReducer,
cart: cartReducer,
ui: uiReducer
});
// Create the store with the combined reducer
const store = createStore(rootReducer);
// The state shape will match the reducer structure:
/*
{
user: { ... }, // Managed by userReducer
products: { ... }, // Managed by productReducer
cart: { ... }, // Managed by cartReducer
ui: { ... } // Managed by uiReducer
}
*/
This approach has several benefits:
- Breaks down complex state logic into manageable pieces
- Allows different developers to work on different reducers
- Makes the codebase more maintainable as it grows
- Creates a predictable state shape that matches the reducer structure
Real-World Analogy: Government Departments
Combining reducers is like how different government departments handle different concerns:
- The Treasury Department handles financial matters
- The Defense Department handles military concerns
- The Justice Department handles legal issues
- The Education Department handles school policies
Each department specializes in a specific domain, with its own internal processes, but together they form a complete government. Similarly, each reducer manages its own slice of state, but together they form the complete application state.
Redux Middleware
Middleware provides a way to extend Redux with custom functionality. It sits between dispatching an action and the moment it reaches the reducer, allowing for side effects, async operations, logging, crash reporting, and more.
// Simple logging middleware
const loggerMiddleware = store => next => action => {
console.log('Dispatching:', action);
const result = next(action);
console.log('Next state:', store.getState());
return result;
};
// Simple API middleware
const apiMiddleware = store => next => action => {
// Check if the action has an API call
if (!action.api) {
return next(action);
}
// Dispatch pending action
store.dispatch({ type: `${action.type}_PENDING` });
// Make the API call
return fetch(action.api.url, action.api.options)
.then(response => response.json())
.then(data => {
// Dispatch success action
store.dispatch({
type: `${action.type}_SUCCESS`,
payload: data
});
return data;
})
.catch(error => {
// Dispatch error action
store.dispatch({
type: `${action.type}_ERROR`,
error: error.message
});
throw error;
});
};
// Apply middleware to the store
import { createStore, applyMiddleware } from 'redux';
import rootReducer from './reducers';
const store = createStore(
rootReducer,
applyMiddleware(
loggerMiddleware,
apiMiddleware
)
);
Common use cases for middleware:
- Logging: Record actions and state changes for debugging
- API Calls: Handle asynchronous operations like API requests
- Error Reporting: Capture and report errors
- Analytics: Track user interactions for analytics
- Routing: Sync navigation with state changes
Popular Redux middleware libraries:
- Redux Thunk: For handling asynchronous actions
- Redux Saga: For more complex async flows using generator functions
- Redux Observable: For reactive programming with RxJS
- Redux Logger: For logging actions and state changes
- redux-persist: For persisting and rehydrating state
Redux vs. Context API
With the introduction of React's Context API and hooks, many developers wonder when to use Redux versus React's built-in state management. Here's a comparison:
| Feature | Redux | Context API |
|---|---|---|
| Learning Curve | Steeper, more concepts to learn | Simpler, fewer concepts |
| Boilerplate | More boilerplate code | Less boilerplate code |
| Middleware | Built-in support for middleware | Requires custom implementation |
| DevTools | Excellent developer tools | Limited debugging tools |
| Performance | Optimized for large applications | Can cause re-renders in large apps |
| Community | Large ecosystem of libraries and tools | Newer, smaller ecosystem |
When to choose Redux:
- Complex state logic that changes frequently
- Need for middleware for side effects
- Large team with established patterns
- Need for robust debugging tools
- Complex UI with many components needing the same data
When to choose Context:
- Simpler applications with less state
- Static data that doesn't change often
- Small to medium-sized applications
- When you want to avoid additional dependencies
- For component-specific or local state
Practice Activities
Activity 1: Create a Basic Redux Store
Implement a simple counter application using Redux:
- Define action types for incrementing, decrementing, and resetting a counter
- Create action creators for each action type
- Write a reducer to handle these actions
- Create a store with the reducer
- Subscribe to store changes and log the state
- Dispatch various actions and observe the state changes
Activity 2: Combine Multiple Reducers
Extend the counter application to manage multiple features:
- Create a counter reducer (from Activity 1)
- Add a new todos reducer for managing a list of todo items
- Add a user reducer for managing user preferences
- Combine these reducers using combineReducers
- Create a store with the combined reducer
- Dispatch various actions to each slice of state
- Observe how the state structure matches the reducer structure
Activity 3: Implement a Custom Middleware
Create a custom middleware for the Redux store:
- Write a logging middleware that logs actions and state changes
- Create a "delay" middleware that adds a time delay to certain actions
- Implement a "validator" middleware that checks actions for required fields
- Apply these middleware to the store created in Activity 2
- Dispatch various actions and observe the middleware in action
Summary
- Redux is a predictable state container for JavaScript applications
- Core principles: single source of truth, state is read-only, changes are made with pure functions
- The Redux flow follows a unidirectional data pattern: action → reducer → state → UI
- Actions describe what happened, reducers specify how the state changes, and the store holds the state
- Complex applications use combined reducers to manage different slices of state
- Middleware extends Redux with additional functionality like async operations and logging
- Redux offers more robustness for complex applications, while Context API is simpler for smaller apps