React & Redux Integration
Redux remains one of the most popular state management libraries in the React ecosystem. While React’s built‑in Context and Hooks can handle many cases, Redux shines in large applications with complex state interactions, predictable state updates, and debuggability. This deep dive covers everything you need to know to integrate Redux into your React applications, complete with code examples, best practices, and tips & tricks to streamline your workflow.
Table of Contents
- Why Use Redux?
- Core Concepts of Redux
- Setting Up React & Redux
- Example 1: Simple Counter App
- Example 2: Fetching Data with Thunks
- Getting Started with Redux Toolkit
- Advanced Patterns & Tips
- Selective Renders with React.memo
- Memoized Selectors with Reselect
- Normalized State Shape
- DevTools & Time‑Travel Debugging
- TypeScript Tips
- Performance Optimization
- Testing Your Redux Logic
- Migrating to Redux Toolkit
- Common Pitfalls & How to Avoid Them
- Conclusion
Why Use Redux?
While React’s useState and useContext cover basic state needs, Redux offers:
- Single Source of Truth: A global store ensures all components refer to the same state snapshot.
- Predictable State Updates: State changes only via pure reducer functions in response to dispatched actions.
- Ease of Debugging: Redux DevTools allow inspection of state at each action, with time‑travel capabilities.
- Maintainability: Clear separation between UI and business logic scales better for large teams.
- Middleware Support: Easily integrate logging, analytics, or side‑effect handling via middleware like thunks or sagas.
Core Concepts of Redux
- Store: Holds the entire application state.
- Action: A plain object describing “what happened” (e.g., { type: 'INCREMENT' }).
- Reducer: A pure function (state, action) => newState that updates slices of the store.
- Dispatch: A method to send actions to the store.
- Middleware: Intercepts dispatched actions for side effects or async flows (e.g., redux-thunk).
Setting Up React & Redux
1. Install Dependencies
npm install react redux react-redux redux-thunk # or with yarn yarn add react redux react-redux redux-thunk
Tip: Consider using Redux Toolkit for simplified configuration (covered in section 6).
2. Create the Redux Store
// src/store/index.js
import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import counterReducer from './counterReducer';
import todosReducer from './todosReducer';
const rootReducer = combineReducers({
counter: counterReducer,
todos: todosReducer,
});
const store = createStore(rootReducer, applyMiddleware(thunk));
export default store;3. Provide the Store to React
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store';
import App from './App';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);Now any component can connect to the Redux store.
Example 1: Simple Counter App
1. Define Actions & Reducer
// src/store/counterReducer.js
// Action Types
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
// Action Creators
export const increment = () => ({ type: INCREMENT });
export const decrement = () => ({ type: DECREMENT });
// Reducer
const initialState = { value: 0 };
export default function counterReducer(state = initialState, action) {
switch (action.type) {
case INCREMENT:
return { value: state.value + 1 };
case DECREMENT:
return { value: state.value - 1 };
default:
return state;
}
}2. Connect React Component
// src/components/Counter.js
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from '../store/counterReducer';
export default function Counter() {
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();
return (
<div>
<h2>Counter: {count}</h2>
<button onClick={() => dispatch(decrement())}>–</button>
<button onClick={() => dispatch(increment())}>+</button>
</div>
);
}- useSelector: Subscribes to selected slice of state.
- useDispatch: Returns the dispatch function.
Example 2: Fetching Data with Thunks
When dealing with async logic, use middleware like redux-thunk.
1. Define Async Action
// src/store/todosReducer.js
import axios from 'axios';
const FETCH_TODOS_START = 'FETCH_TODOS_START';
const FETCH_TODOS_SUCCESS = 'FETCH_TODOS_SUCCESS';
const FETCH_TODOS_ERROR = 'FETCH_TODOS_ERROR';
export const fetchTodos = () => async (dispatch) => {
dispatch({ type: FETCH_TODOS_START });
try {
const response = await axios.get('https://jsonplaceholder.typicode.com/todos');
dispatch({ type: FETCH_TODOS_SUCCESS, payload: response.data });
} catch (error) {
dispatch({ type: FETCH_TODOS_ERROR, error: error.message });
}
};
const initialState = {
loading: false,
items: [],
error: null,
};
export default function todosReducer(state = initialState, action) {
switch (action.type) {
case FETCH_TODOS_START:
return { ...state, loading: true, error: null };
case FETCH_TODOS_SUCCESS:
return { ...state, loading: false, items: action.payload };
case FETCH_TODOS_ERROR:
return { ...state, loading: false, error: action.error };
default:
return state;
}
}2. Display in Component
// src/components/TodoList.js
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchTodos } from '../store/todosReducer';
export default function TodoList() {
const { items, loading, error } = useSelector((state) => state.todos);
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchTodos());
}, [dispatch]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{items.slice(0, 10).map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}Getting Started with Redux Toolkit
Redux Toolkit (RTK) simplifies store setup, reducer creation, and side‑effect logic.
1. Install
npm install @reduxjs/toolkit
2. Create Store & Slice
// src/store/store.js
import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';
// AsyncThunk for fetching todos
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await axios.get('https://jsonplaceholder.typicode.com/todos');
return response.data;
});
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [], loading: false, error: null },
reducers: {
// synchronous reducers here
},
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, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state) => state + 1,
decrement: (state) => state - 1,
},
});
export const { increment, decrement } = counterSlice.actions;
const store = configureStore({
reducer: {
counter: counterSlice.reducer,
todos: todosSlice.reducer,
},
});
export default store;3. Use in Components
Same as before, except you import actions and thunks from slices:
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from '../store/store';
import { fetchTodos } from '../store/store';
export default function App() {
const count = useSelector((state) => state.counter);
const { items, loading, error } = useSelector((state) => state.todos);
const dispatch = useDispatch();
useEffect(() => { dispatch(fetchTodos()); }, [dispatch]);
return (
<div>
<h1>Counter: {count}</h1>
<button onClick={() => dispatch(decrement())}>−</button>
<button onClick={() => dispatch(increment())}>+</button>
{loading && <p>Loading todos...</p>}
{error && <p>Error: {error}</p>}
<ul>
{items.slice(0, 5).map((t) => <li key={t.id}>{t.title}</li>)}
</ul>
</div>
);
}Advanced Patterns & Tips
Selective Renders with React.memo
Wrap pure components to avoid unnecessary re‑renders:
const TodoItem = React.memo(({ todo }) => {
console.log('Rendering', todo.id);
return <li>{todo.title}</li>;
});Memoized Selectors with Reselect
Install Reselect:
npm install reselect
Create a memoized selector:
import { createSelector } from 'reselect';
const selectTodos = (state) => state.todos.items;
export const selectCompletedTodos = createSelector(
[selectTodos],
(todos) => todos.filter((t) => t.completed)
);Use in component:
const completed = useSelector(selectCompletedTodos);
Normalized State Shape
Store entities by ID for easier updates:
const normalizedState = {
users: { '1': { id: '1', name: 'Alice' }, ... },
posts: { '10': { id: '10', title: 'Hello' }, ... },
};Consider using RTK’s createEntityAdapter to automate normalization.
DevTools & Time‑Travel Debugging
Enable Redux DevTools:
const store = configureStore({
reducer: { /* ... */ },
devTools: process.env.NODE_ENV !== 'production',
});Use the Redux DevTools browser extension to inspect actions and state history.
TypeScript Tips
- Define action payloads as interfaces.
- Use ReturnType<typeof actionCreator> to infer action types.
- Leverage RTK’s built‑in TS support — slices infer state and action types automatically.
Performance Optimization
- Chunk Your Store: Split reducers so each component only subscribes to needed slices.
- Lazy Load Reducers: Dynamically inject reducers for large features.
- Batching: Use React’s unstable_batchedUpdates for multiple dispatches.
- Immutable Updates: Always return new state objects; avoid deep nesting where possible.
Testing Your Redux Logic
Reducer Tests
import reducer, { increment, decrement } from './counterSlice';
test('should handle increment', () => {
expect(reducer(0, increment())).toEqual(1);
});Thunk Tests
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fetchTodos } from './todosSlice';
import axios from 'axios';
jest.mock('axios');
const mockStore = configureMockStore([thunk]);
test('fetchTodos success', async () => {
axios.get.mockResolvedValue({ data: [{ id: 1, title: 'Test' }] });
const store = mockStore({ todos: [] });
await store.dispatch(fetchTodos());
const actions = store.getActions();
expect(actions[0].type).toBe('todos/fetchTodos/pending');
expect(actions[1].type).toBe('todos/fetchTodos/fulfilled');
});Migrating to Redux Toolkit
If you have an existing Redux codebase:
- Install RTK and replace createStore with configureStore.
- Convert reducers into slices via createSlice.
- Swap middleware setup to RTK’s default (includes redux-thunk).
- Replace action creators with auto‑generated slice actions.
- Refactor selectors to use Reselect & slice selectors.
The migration often reduces boilerplate by 30–50%.
Common Pitfalls & How to Avoid Them
Pitfall Solution
| Mutating state directly | Always return new objects or use Immer in RTK
| Over‑subscribing (too many useSelector) | Combine selectors or lift state up
| Deeply nested state | Normalize using entity adapters
| Excessive re‑renders | Memoize components and selectors
| Not cleaning up subscriptions | Unsubscribe in useEffect cleanup when needed
Conclusion
Integrating Redux with React provides a powerful pattern for managing complex state in large applications. By following the examples and tips above—leveraging Redux Toolkit, memoization, DevTools, and testing strategies—you’ll build predictable, maintainable, and performant React+Redux apps.