Redux is particularly useful for complex applications with multiple components that need to share state. It implements the Flux architecture pattern and has become the de-facto standard for state management in React applications.
Redux is a predictable state management library that uses actions, reducers, and a store to manage application state in a centralized location accessible from any component.
Why Use Redux?
Problems Redux Solves
- Prop Drilling: Avoid passing props through multiple component levels
- State Consistency: Ensure all components access the same state
- Time-Travel Debugging: Track every state change for debugging
- Middleware Support: Intercept and modify actions before they reach reducers
- Predictability: Pure functions ensure consistent state transitions
When to Use Redux
| Use Redux When | Consider Alternatives |
|---|---|
| Large, complex applications with shared state | Simple apps with minimal state |
| Multiple components need the same data | Only a few components share state |
| Complex async operations | Simple data fetching |
| Need time-travel debugging | Standard debugging is sufficient |
Core Redux Concepts
1. Actions
Actions are plain JavaScript objects that describe what happened. They have a type property and optional payload:
const addTodoAction = {
type: ‘todos/addTodo’,
payload: { id: 1, text: ‘Learn Redux’ }
};const toggleTodoAction = {
type: ‘todos/toggleTodo’,
payload: 1
};
2. Reducers
Reducers are pure functions that take the current state and an action, returning a new state:
switch (action.type) {
case ‘todos/addTodo’:
return […state, action.payload];
case ‘todos/removeTodo’:
return state.filter(todo => todo.id !== action.payload);
default:
return state;
}
}
3. Store
The store holds the entire application state. It dispatches actions and notifies subscribers of state changes:
// Get current state
console.log(store.getState());
// Dispatch an action
store.dispatch(addTodoAction);
// Subscribe to changes
store.subscribe(() => {
console.log(‘State updated:’, store.getState());
});
Redux Flow
- Component dispatches an action
- Store receives the action
- Reducer processes the action and returns new state
- Store updates with new state
- Subscribers are notified of the change
- Components re-render with new state
Setting Up Redux with Redux Toolkit
Installation
Redux Toolkit is the recommended way to use Redux. It simplifies setup and provides helpful utilities:
Basic Setup Example
import { Provider } from ‘react-redux’;
import store from ‘./store’;
import App from ‘./App’;export default function Root() {
return (
<Provider store={store}>
<App />
</Provider>
);
}
Redux Toolkit includes: configureStore for setup, createSlice for reducers and actions, and immer for immutable updates.
Creating Slices with createSlice
What is a Slice?
A slice contains a reducer and its associated actions in one place. Redux Toolkit’s createSlice automates action creator generation:
Todo Slice Example
import { createSlice } from ‘@reduxjs/toolkit’;const todoSlice = createSlice({
name: ‘todos’,
initialState: {
items: [],
loading: false,
error: null
},
reducers: {
addTodo: (state, action) => {
state.items.push(action.payload);
},
removeTodo: (state, action) => {
state.items = state.items.filter(
todo => todo.id !== action.payload
);
},
toggleTodo: (state, action) => {
const todo = state.items.find(
t => t.id === action.payload
);
if (todo) {
todo.completed = !todo.completed;
}
}
}
});
export const { addTodo, removeTodo, toggleTodo } = todoSlice.actions;
export default todoSlice.reducer;
Handling Async Operations
‘todos/fetchTodos’,
async () => {
const response = await fetch(‘/api/todos’);
return response.json();
}
);
const todoSlice = createSlice({
name: ‘todos’,
initialState: { items: [], loading: false },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.loading = true;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state) => {
state.loading = false;
});
}
});
Configuring the Store
Setting Up configureStore
import { configureStore } from ‘@reduxjs/toolkit’;
import todoReducer from ‘./slices/todoSlice’;
import userReducer from ‘./slices/userSlice’;export const store = configureStore({
reducer: {
todos: todoReducer,
user: userReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat([
// Add custom middleware here
])
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Multiple Reducers
Combine multiple slices to create a complex state tree:
reducer: {
todos: todoReducer,
user: userReducer,
auth: authReducer,
ui: uiReducer,
}
});// State structure:
// {
// todos: { items: [], loading: false },
// user: { name: ‘John’, email: ‘john@example.com’ },
// auth: { token: null, isAuthenticated: false },
// ui: { sidebarOpen: true, darkMode: false }
// }
Using Redux Hooks
useSelector Hook
Access state from Redux store in components:
const todos = useSelector((state) => state.todos.items);
const loading = useSelector((state) => state.todos.loading);
if (loading) return <p>Loading…</p>;
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}
useDispatch Hook
Dispatch actions from components:
import { addTodo, removeTodo } from ‘./slices/todoSlice’;export default function AddTodo() {
const dispatch = useDispatch();
const handleAdd = () => {
dispatch(addTodo({
id: Date.now(),
text: ‘New todo’,
completed: false
}));
};
return <button onClick={handleAdd}>Add Todo</button>;
}
Combined Example
import { toggleTodo, removeTodo } from ‘./slices/todoSlice’;export default function TodoItem({ id }) {
const dispatch = useDispatch();
const todo = useSelector(
(state) => state.todos.items.find(t => t.id === id)
);
if (!todo) return null;
return (
<li>
<input
type=”checkbox”
checked={todo.completed}
onChange={() => dispatch(toggleTodo(id))}
/>
<span>{todo.text}</span>
<button onClick={() => dispatch(removeTodo(id))}>
Delete
</button>
</li>
);
}
Async Actions with createAsyncThunk
Handling Loading States
‘user/fetchUser’,
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(‘Failed to fetch’);
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);const userSlice = createSlice({
name: ‘user’,
initialState: {
data: null,
loading: false,
error: null
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
}
});
Using Async Actions in Components
import { useDispatch, useSelector } from ‘react-redux’;
import { fetchUser } from ‘./slices/userSlice’;export default function UserProfile({ userId }) {
const dispatch = useDispatch();
const { data: user, loading, error } = useSelector(
(state) => state.user
);
useEffect(() => {
dispatch(fetchUser(userId));
}, [userId, dispatch]);
if (loading) return <p>Loading…</p>;
if (error) return <p>Error: {error}</p>;
if (!user) return null;
return <div><h1>{user.name}</h1></div>;
}
Selectors and Reselect
Creating Selectors
Extract selection logic into reusable functions:
export const selectAllTodos = (state) => state.todos.items;export const selectCompletedTodos = (state) =>
state.todos.items.filter(todo => todo.completed);
export const selectActiveTodos = (state) =>
state.todos.items.filter(todo => !todo.completed);
export const selectTodoCount = (state) =>
state.todos.items.length;
export const selectTodoById = (id) => (state) =>
state.todos.items.find(todo => todo.id === id);
Using Selectors in Components
import { selectActiveTodos, selectTodoCount } from ‘./selectors/todoSelectors’;export default function TodoStats() {
const activeTodos = useSelector(selectActiveTodos);
const count = useSelector(selectTodoCount);
return (
<div>
<p>Total: {count}</p>
<p>Active: {activeTodos.length}</p>
</div>
);
}
Memoized Selectors with Reselect
// Memoized selector – only recalculates if todos change
export const selectActiveTodos = createSelector(
[selectTodos],
(todos) => todos.filter(todo => !todo.completed)
);
// Selector with parameter
export const selectTodosByStatus = createSelector(
[selectTodos, (_, status) => status],
(todos, status) =>
status === ‘active’
? todos.filter(t => !t.completed)
: todos.filter(t => t.completed)
);
Best Practices
1. Normalize State Shape
Keep state flat and avoid deeply nested data:
const badState = {
users: [
{
id: 1,
name: ‘John’,
posts: [
{ id: 1, title: ‘Post 1’ }
]
}
]
};// ✅ Do: Normalized structure
const goodState = {
users: {
byId: { 1: { id: 1, name: ‘John’ } },
allIds: [1]
},
posts: {
byId: { 1: { id: 1, title: ‘Post 1’, userId: 1 } },
allIds: [1]
}
};
2. Keep Reducers Pure
const badReducer = (state, action) => {
state.todos.push(action.payload);
return state;
};// ✅ Do: Return new state
const goodReducer = (state = [], action) => {
switch (action.type) {
case ‘ADD_TODO’:
return […state, action.payload];
default:
return state;
}
};
3. Use Redux DevTools
import { configureStore } from ‘@reduxjs/toolkit’;const store = configureStore({
reducer: {
todos: todoReducer,
}
// DevTools integration is automatic!
});
// Install Redux DevTools browser extension to debug
4. Handle Side Effects Properly
- Use createAsyncThunk for API calls
- Never put side effects in reducers
- Handle loading and error states explicitly
- Abort ongoing requests when components unmount
5. Code Splitting with Lazy Reducers
injectReducer(‘lazyFeature’, lazyReducer);
return <div>…</div>;
}