Redux Store in Depth
The Redux store is the heart of every Redux application. It holds the application state, allows state updates through actions, and notifies listeners when the state changes.
Creating a Redux Store
The createStore function is the primary way to create a Redux store. It takes three arguments: a reducer function, an optional initial state, and optional enhancers.
import { createStore } from 'redux';
// Basic store creation
const store = createStore(rootReducer);
// Store with initial state
const store = createStore(
rootReducer,
{ counter: 0, todos: [] }
);
// Store with enhancers (like middleware)
const store = createStore(
rootReducer,
initialState,
applyMiddleware(...middleware)
);
Store API Methods
The Redux store provides a small API with just four methods:
getState(): Returns the current state tree of your applicationdispatch(action): Dispatches an action to trigger a state changesubscribe(listener): Adds a change listener that's called any time an action is dispatchedreplaceReducer(nextReducer): Replaces the reducer currently used by the store (advanced use case)
// Getting the current state
const currentState = store.getState();
console.log(currentState);
// Dispatching an action
store.dispatch({
type: 'INCREMENT_COUNTER',
payload: 5
});
// Subscribing to state changes
const unsubscribe = store.subscribe(() => {
console.log('State updated:', store.getState());
});
// Later, when you want to stop listening:
unsubscribe();
Real-World Analogy: Message Distribution System
The Redux store functions like a corporate message distribution system:
- getState() - Like checking the company bulletin board for current information
- dispatch(action) - Like submitting a new announcement to be processed and posted
- subscribe(listener) - Like signing up for email notifications when new announcements are posted
- replaceReducer() - Like changing the procedure for how announcements are processed and posted
Just as a company maintains a single, consistent bulletin board for all announcements, Redux maintains a single store as the source of truth for application state.
Store Enhancers and Middleware
Store enhancers let you customize the store's behavior by adding extra capabilities. The most common enhancer is applyMiddleware, but you can also create custom enhancers.
Middleware
Middleware provides a way to extend Redux with custom functionality by intercepting actions before they reach the reducer. It's ideal for logging, crash reporting, asynchronous API calls, and more.
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
import rootReducer from './reducers';
// Apply multiple middleware
const store = createStore(
rootReducer,
applyMiddleware(
thunk, // Allows async actions
logger // Logs actions and state changes
)
);
The anatomy of a middleware function:
// Middleware signature
const myMiddleware = store => next => action => {
// Code to run before the action reaches the next middleware or reducer
console.log('Dispatching:', action);
// Call the next middleware or the reducer
const result = next(action);
// Code to run after the reducer has processed the action
console.log('Next state:', store.getState());
// Return the result (usually the action)
return result;
};
DevTools Extension
Redux DevTools Extension is a powerful enhancer that enables time-travel debugging, action replay, and state inspection.
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers';
// Setup enhancers
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
// Create store with middleware and DevTools
const store = createStore(
rootReducer,
composeEnhancers(applyMiddleware(thunk))
);
Why Use Multiple Middleware?
Different middleware serve different purposes in a production application:
- redux-thunk: Handles asynchronous operations (API calls, etc.)
- redux-logger: Logs all actions and state changes for debugging
- redux-persist: Saves and rehydrates state to/from localStorage
- redux-saga: Manages complex side effects with a more declarative approach
- Custom middleware: For application-specific needs like analytics or validation
Each middleware adds a specific capability, and they can be combined to create a powerful, extensible Redux store.
Actions in Depth
Actions are payloads of information that send data from your application to your Redux store. They are the only source of information for the store and are sent using store.dispatch().
Action Structure
An action is a plain JavaScript object with a type property. Any other properties are up to you but should follow consistent patterns.
// Basic action
{
type: 'ADD_TODO',
payload: {
id: 1,
text: 'Learn Redux',
completed: false
}
}
// Action with error flag
{
type: 'FETCH_USER_FAILURE',
error: true,
payload: new Error('Network Error'),
meta: {
userId: 123
}
}
Action Types
Action types are string constants that indicate the type of action being performed. Using constants makes it easier to manage and reuse action types across your application.
// action-types.js
export const ADD_TODO = 'todos/add';
export const TOGGLE_TODO = 'todos/toggle';
export const DELETE_TODO = 'todos/delete';
export const FETCH_USERS_REQUEST = 'users/fetchRequest';
export const FETCH_USERS_SUCCESS = 'users/fetchSuccess';
export const FETCH_USERS_FAILURE = 'users/fetchFailure';
Best practices for action types:
- Use string constants to prevent typos
- Use a domain/eventName pattern for better organization
- Group related constants in the same file
- Use suffixes like _REQUEST, _SUCCESS, and _FAILURE for async actions
Action Creators
Action creators are functions that create and return action objects. They make it easier to create actions with the correct structure and can encapsulate action creation logic.
// action-creators.js
import {
ADD_TODO,
TOGGLE_TODO,
DELETE_TODO
} from './action-types';
// Synchronous action creators
export function addTodo(text) {
return {
type: ADD_TODO,
payload: {
id: Date.now(),
text,
completed: false
}
};
}
export function toggleTodo(id) {
return {
type: TOGGLE_TODO,
payload: { id }
};
}
export function deleteTodo(id) {
return {
type: DELETE_TODO,
payload: { id }
};
}
Async Action Creators with Thunk
Redux Thunk middleware allows action creators to return functions instead of objects, enabling asynchronous operations like API calls.
import {
FETCH_USERS_REQUEST,
FETCH_USERS_SUCCESS,
FETCH_USERS_FAILURE
} from './action-types';
// Async action creator with redux-thunk
export function fetchUsers() {
return async (dispatch, getState) => {
try {
// Dispatch request action
dispatch({ type: FETCH_USERS_REQUEST });
// Make API call
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error('Network response was not ok');
}
const users = await response.json();
// Dispatch success action with data
dispatch({
type: FETCH_USERS_SUCCESS,
payload: users
});
return users;
} catch (error) {
// Dispatch failure action with error
dispatch({
type: FETCH_USERS_FAILURE,
error: true,
payload: error.message
});
throw error;
}
};
}
Real-World Analogy: Business Communication
Actions are like standardized business communication forms:
- The type is like the form title (e.g., "Expense Reimbursement Request")
- The payload contains the specific details (e.g., expense amount, date, category)
- Action creators are like form-filling assistants who ensure everything is filled out correctly
- Async actions are like processes that require multiple forms and approvals (e.g., a business trip request that triggers multiple department approvals)
Just as businesses standardize their forms to ensure consistency and clear communication, Redux standardizes actions to ensure predictable state updates.
Reducers in Depth
Reducers are pure functions that take the current state and an action, and return a new state. They specify how the application's state changes in response to actions.
Reducer Function Signature
A reducer must be a pure function with the following signature:
function reducer(state = initialState, action) {
// Return new state based on action type
switch (action.type) {
case ACTION_TYPE_1:
return newState1;
case ACTION_TYPE_2:
return newState2;
default:
return state;
}
}
Key characteristics of reducers:
- They never mutate the state argument
- They return the existing state for unrecognized actions
- They return a new state object when changes are needed
- They have no side effects (no API calls, no DOM manipulation, etc.)
- They are deterministic (same input = same output)
Immutable State Updates
Redux requires state updates to be immutable - you create a new object rather than modifying the existing one. Here are techniques for updating state immutably:
// Updating objects immutably
function updateObjectInArray(array, itemId, updateCallback) {
return array.map(item => {
if (item.id !== itemId) {
// This isn't the item we care about - keep it as-is
return item;
}
// Otherwise, this is the one we want - create a new object
return {
...item,
...updateCallback(item)
};
});
}
// Usage in a reducer
function todosReducer(state = [], action) {
switch (action.type) {
case 'UPDATE_TODO':
return updateObjectInArray(
state,
action.payload.id,
todo => ({ completed: !todo.completed })
);
case 'ADD_TODO':
return [
...state,
{
id: action.payload.id,
text: action.payload.text,
completed: false
}
];
case 'REMOVE_TODO':
return state.filter(todo => todo.id !== action.payload.id);
default:
return state;
}
}
Common State Update Patterns
Here are the common patterns for immutable state updates:
1. Updating an Object
// Original object
const state = { name: 'John', age: 30 };
// Immutable update
const newState = {
...state,
age: 31 // Only update the age
};
2. Updating a Nested Object
// Original object
const state = {
user: {
name: 'John',
address: {
city: 'New York',
zip: '10001'
}
}
};
// Immutable update
const newState = {
...state,
user: {
...state.user,
address: {
...state.user.address,
city: 'Boston'
}
}
};
3. Adding to an Array
// Original array
const state = [1, 2, 3];
// Immutable update
const newState = [...state, 4];
4. Removing from an Array
// Original array
const state = [1, 2, 3, 4];
// Immutable update
const newState = state.filter(item => item !== 3);
5. Updating an Item in an Array
// Original array
const state = [
{ id: 1, text: 'Learn Redux', completed: false },
{ id: 2, text: 'Learn React', completed: true }
];
// Immutable update
const newState = state.map(item =>
item.id === 1
? { ...item, completed: true }
: item
);
Creating Reusable Reducers
As your application grows, you'll want to reuse common reducer logic and compose reducers together.
// Generic createReducer function
function createReducer(initialState, handlers) {
return function reducer(state = initialState, action) {
if (handlers.hasOwnProperty(action.type)) {
return handlers[action.type](state, action);
}
return state;
};
}
// Using createReducer to define a todos reducer
const todosReducer = createReducer([], {
'ADD_TODO': (state, action) => [
...state,
{
id: action.payload.id,
text: action.payload.text,
completed: false
}
],
'TOGGLE_TODO': (state, action) =>
state.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
),
'REMOVE_TODO': (state, action) =>
state.filter(todo => todo.id !== action.payload.id)
});
Combining Reducers
For complex applications, you'll want to split your reducers to handle different parts of the state. Redux provides combineReducers to make this easy.
import { combineReducers } from 'redux';
// Reducer for todos
function todos(state = [], action) {
// Handle todo-related actions
switch (action.type) {
case 'ADD_TODO':
case 'TOGGLE_TODO':
case 'REMOVE_TODO':
// Return new todos state
// ...
default:
return state;
}
}
// Reducer for visibility filter
function visibilityFilter(state = 'SHOW_ALL', action) {
// Handle filter-related actions
switch (action.type) {
case 'SET_VISIBILITY_FILTER':
return action.payload.filter;
default:
return state;
}
}
// Reducer for user
function user(state = null, action) {
// Handle user-related actions
switch (action.type) {
case 'USER_LOGIN':
case 'USER_LOGOUT':
// Return new user state
// ...
default:
return state;
}
}
// Combine all reducers
const rootReducer = combineReducers({
todos,
visibilityFilter,
user
});
// The state shape will be:
/*
{
todos: [...],
visibilityFilter: 'SHOW_ALL',
user: { ... }
}
*/
Real-World Analogy: Corporate Departments
Reducers can be compared to specialized departments in a company:
- Each department has a specific area of responsibility (like HR, Finance, IT)
- When a company-wide memo is sent, each department only acts on relevant information
- All departments together create the overall company structure
When an action (like "New Budget Approval") is dispatched, only relevant departments (Finance) will update their state, while others maintain their current state - just like reducers only respond to actions they recognize.
State Normalization
For complex applications with relational data, normalizing your state shape can improve performance and maintainability. Normalization means storing data in a normalized form, similar to a database:
- Each type of data gets its own "table" in the state
- Each "table" stores items as objects with IDs as keys
- References to other items are done by storing their IDs
- Arrays of IDs are used to indicate ordering
Non-Normalized vs. Normalized State
// Non-normalized state
{
posts: [
{
id: 1,
author: { id: 1, name: 'User 1' },
title: 'Post 1',
comments: [
{ id: 1, author: { id: 2, name: 'User 2' }, text: 'Comment 1' },
{ id: 2, author: { id: 3, name: 'User 3' }, text: 'Comment 2' }
]
},
{
id: 2,
author: { id: 2, name: 'User 2' },
title: 'Post 2',
comments: [
{ id: 3, author: { id: 1, name: 'User 1' }, text: 'Comment 3' },
{ id: 4, author: { id: 3, name: 'User 3' }, text: 'Comment 4' }
]
}
],
users: [
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' },
{ id: 3, name: 'User 3' }
]
}
// Normalized state
{
users: {
byId: {
'1': { id: 1, name: 'User 1' },
'2': { id: 2, name: 'User 2' },
'3': { id: 3, name: 'User 3' }
},
allIds: [1, 2, 3]
},
posts: {
byId: {
'1': {
id: 1,
author: 1,
title: 'Post 1',
comments: [1, 2]
},
'2': {
id: 2,
author: 2,
title: 'Post 2',
comments: [3, 4]
}
},
allIds: [1, 2]
},
comments: {
byId: {
'1': { id: 1, author: 2, text: 'Comment 1' },
'2': { id: 2, author: 3, text: 'Comment 2' },
'3': { id: 3, author: 1, text: 'Comment 3' },
'4': { id: 4, author: 3, text: 'Comment 4' }
},
allIds: [1, 2, 3, 4]
}
}
Benefits of normalization:
- Avoids data duplication
- Makes updates more efficient and less error-prone
- Simplifies reducer logic
- Makes it easier to implement features like caching and pagination
Real-World Analogy: Library Catalog System
State normalization is similar to how a library organizes its catalog:
- Books are stored in one database table with unique IDs
- Authors are stored in another table with their own IDs
- Publishers are in yet another table
- Book records reference authors and publishers by ID rather than duplicating all their information
This system makes it easy to update author information in one place (rather than finding every book by that author), just as normalized Redux state makes it easier to update a user's details without searching through posts and comments.
Selectors
Selectors are functions that extract specific pieces of information from a store state. They help encapsulate the state structure and provide reusable logic for computing derived data.
// Basic selectors
const getTodos = state => state.todos;
const getVisibilityFilter = state => state.visibilityFilter;
// Derived data selector
const getVisibleTodos = state => {
const todos = getTodos(state);
const filter = getVisibilityFilter(state);
switch (filter) {
case 'SHOW_ALL':
return todos;
case 'SHOW_COMPLETED':
return todos.filter(todo => todo.completed);
case 'SHOW_ACTIVE':
return todos.filter(todo => !todo.completed);
default:
return todos;
}
};
// Usage in component
function TodoList({ todos }) {
return (
{todos.map(todo => (
- {todo.text}
))}
);
}
// Connect component to Redux
import { connect } from 'react-redux';
const mapStateToProps = state => ({
todos: getVisibleTodos(state)
});
export default connect(mapStateToProps)(TodoList);
For complex selectors that might be computationally expensive, you can use libraries like reselect to memoize the results:
import { createSelector } from 'reselect';
// Input selectors
const getTodos = state => state.todos;
const getVisibilityFilter = state => state.visibilityFilter;
// Memoized selector
const getVisibleTodos = createSelector(
[getTodos, getVisibilityFilter],
(todos, filter) => {
switch (filter) {
case 'SHOW_ALL':
return todos;
case 'SHOW_COMPLETED':
return todos.filter(todo => todo.completed);
case 'SHOW_ACTIVE':
return todos.filter(todo => !todo.completed);
default:
return todos;
}
}
);
// Now getVisibleTodos will only recalculate when todos or filter changes
Why Use Selectors?
Selectors provide several benefits:
- Encapsulation: Components don't need to know the state structure
- Reusability: The same selector can be used by multiple components
- Derived Data: Complex calculations can be moved out of components
- Performance: Memoized selectors can avoid unnecessary recalculations
- Testability: Selectors are pure functions that are easy to test
For example, a "getTotalPrice" selector could calculate the total price of all items in a shopping cart, including discounts and taxes, without the component needing to know the details of how prices are stored or calculated.
Practice Activities
Activity 1: Building a Todo App with Redux
Create a simple todo application with the following features:
- Adding new todos
- Toggling todo completion status
- Deleting todos
- Filtering todos (all, active, completed)
Implement the following Redux components:
- Action types and action creators for all operations
- A reducer to handle todo state
- A reducer to handle visibility filter
- A combined root reducer
- A Redux store with middleware for logging
- Selectors for getting visible todos
Activity 2: Implementing Async Actions
Extend the todo app to include these async features:
- Loading initial todos from a mock API
- Saving todos to a mock API when they change
- Showing loading indicators during API calls
- Handling API errors gracefully
Implement these Redux components:
- Async action creators using redux-thunk
- Loading state in the reducer
- Error state in the reducer
- A middleware for API calls
Activity 3: State Normalization
Extend the todo app to support more complex data:
- Add categories for todos
- Add users who can be assigned to todos
- Add comments on todos
Implement a normalized state structure:
- Normalize todos, categories, users, and comments
- Use IDs to reference related entities
- Create selectors to reconstruct the full object graphs
- Update reducers to maintain the normalized structure
Summary
- The Redux store is the central repository of your application state
- Store enhancers like middleware extend Redux with additional functionality
- Actions are plain objects with a type property that describe what happened
- Action creators produce action objects with consistent structure
- Async action creators use middleware like redux-thunk to handle side effects
- Reducers are pure functions that calculate a new state based on the current state and an action
- Immutable state updates are essential for Redux's predictability
- Normalizing state helps manage complex, relational data efficiently
- Selectors extract data from the state and compute derived values