How to Implement Redux
How to Implement Redux Redux is a predictable state management library for JavaScript applications, most commonly used with React. It provides a centralized store to manage the state of your entire application, making it easier to debug, test, and maintain complex user interfaces. While modern alternatives like Zustand or React’s built-in Context API have gained popularity, Redux remains a powerfu
How to Implement Redux
Redux is a predictable state management library for JavaScript applications, most commonly used with React. It provides a centralized store to manage the state of your entire application, making it easier to debug, test, and maintain complex user interfaces. While modern alternatives like Zustand or Reacts built-in Context API have gained popularity, Redux remains a powerful and widely adopted solutionespecially for large-scale applications with intricate state logic.
Implementing Redux correctly requires understanding its core principles: a single source of truth, immutable state updates, and pure functions for state changes. This tutorial walks you through the complete process of implementing Redux from scratchwhether youre starting a new project or refactoring an existing one. By the end, youll have a solid foundation to build scalable, maintainable applications with Redux, backed by industry best practices and real-world examples.
Step-by-Step Guide
1. Set Up Your Project Environment
Before implementing Redux, ensure your development environment is properly configured. If youre using React, the easiest way to start is with Create React App (CRA) or Vite. For this guide, well assume youre using React with Vite, but the steps are nearly identical for CRA or other frameworks.
First, create a new React project:
npm create vite@latest my-redux-app -- --template react
Then navigate into the project directory and install the required Redux packages:
cd my-redux-app
npm install @reduxjs/toolkit react-redux
@reduxjs/toolkit is the official, opinionated toolkit for Redux development. It simplifies many common Redux patterns by reducing boilerplate code and providing utilities like createSlice and configureStore. react-redux is the official React binding library that connects Redux to your React components.
Once installed, verify your package.json includes:
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@reduxjs/toolkit": "^2.2.0",
"react-redux": "^9.1.0"
}
2. Create the Redux Store
The Redux store is the central hub where your applications state lives. All state changes are dispatched as actions and processed by reducers to produce new state. To create the store, youll use configureStore from @reduxjs/toolkit.
Create a new directory called store in your src folder. Inside, create a file named index.js:
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
export const store = configureStore({
reducer: {},
});
export default store;
At this point, the store is empty because we havent added any reducers. Well fix that in the next step. The configureStore function automatically sets up middleware like Redux Thunk (for async logic) and enables Redux DevTools for debugging.
3. Define a Slice: State, Actions, and Reducers
A slice is a piece of the Redux state tree, along with the reducers and actions that manage it. Redux Toolkits createSlice function automates the creation of action creators and reducers based on a name and initial state.
Lets create a simple counter slice. In your src folder, create a new directory called features, and inside it, create counter. Then create counterSlice.js:
// src/features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
value: 0,
};
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
Heres whats happening:
- We define an initialState with a
valueproperty set to 0. - We define three reducers:
increment,decrement, andincrementByAmount. Each reducer receives the current state and an action object. Notice we mutate state directlythis is safe in Redux Toolkit because it uses Immer under the hood to create immutable updates. - We export the generated action creators (
increment, etc.) and the reducer function.
4. Combine Slices and Add to the Store
As your application grows, youll have multiple slices. You need to combine them into a single root reducer and pass it to the store.
Go back to your src/store/index.js and update it:
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export default store;
Weve now added the counter reducer to the store under the key counter. This means your state tree will look like:
{
counter: {
value: 0
}
}
5. Wrap Your App with the Provider
To make the Redux store available to all components in your React app, you need to wrap your root component with the Provider component from react-redux.
Open src/main.jsx (or src/index.js if using CRA) and update it:
// src/main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import { Provider } from 'react-redux'
import { store } from './store'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
Now every component inside <App> can access the Redux store.
6. Connect Components to the Store
To read from or dispatch actions to the Redux store, you use two hooks from react-redux: useSelector and useDispatch.
Lets create a simple counter component. In src/features/counter, create Counter.jsx:
// src/features/counter/Counter.jsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount } from './counterSlice';
const Counter = () => {
const count = useSelector(state => state.counter.value);
const dispatch = useDispatch();
return (
<div>
<h3>Count: {count}</h3>
<button onClick={() => dispatch(increment())}>Increment</button>
<button onClick={() => dispatch(decrement())}>Decrement</button>
<button onClick={() => dispatch(incrementByAmount(5))}>Increment by 5</button>
</div>
);
};
export default Counter;
Now, import and use this component in your App.jsx:
// src/App.jsx
import Counter from './features/counter/Counter';
function App() {
return (
<div className="App">
<h1>Redux Counter Example</h1>
<Counter />
</div>
);
}
export default App;
Run your app with npm run dev. You should now see a counter with three buttons that update the state via Redux.
7. Handling Async Logic with Redux Thunk
Redux by itself is synchronous. To handle async operationslike fetching data from an APIyou need middleware. Redux Toolkit includes Redux Thunk by default, which allows you to write action creators that return a function instead of an action object.
Lets create a simple user data fetcher. Create a new slice: src/features/user/userSlice.js:
// src/features/user/userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';
// Async thunk to fetch user data
export const fetchUser = createAsyncThunk(
'user/fetchUser',
async (_, { rejectWithValue }) => {
try {
const response = await axios.get('https://jsonplaceholder.typicode.com/users/1');
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data || 'Failed to fetch user');
}
}
);
const initialState = {
data: null,
loading: false,
error: null,
};
export const userSlice = createSlice({
name: 'user',
initialState,
reducers: {},
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;
});
},
});
export default userSlice.reducer;
Now, update your store to include this new reducer:
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import userReducer from '../features/user/userSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
user: userReducer,
},
});
export default store;
Create a component to display the user data:
// src/features/user/User.jsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchUser } from './userSlice';
const User = () => {
const { data, loading, error } = useSelector(state => state.user);
const dispatch = useDispatch();
const handleFetch = () => {
dispatch(fetchUser());
};
return (
<div>
<h3>User Data</h3>
<button onClick={handleFetch}>Fetch User</button>
{loading && <p>Loading...</p>}
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{data && (
<div>
<p>Name: {data.name}</p>
<p>Email: {data.email}</p>
<p>Phone: {data.phone}</p>
</div>
)}
</div>
);
};
export default User;
Add it to your App:
// src/App.jsx
import Counter from './features/counter/Counter';
import User from './features/user/User';
function App() {
return (
<div className="App">
<h1>Redux Counter and User Example</h1>
<Counter />
<User />
</div>
);
}
export default App;
Now, clicking Fetch User will trigger an async request, update the state, and render the dataall managed by Redux.
Best Practices
1. Structure Your State by Feature
Organize your Redux code by domain or feature, not by type (e.g., actions, reducers, constants). This improves maintainability and makes it easier to locate code when scaling.
Bad structure:
src/
??? actions/
? ??? counterActions.js
? ??? userActions.js
??? reducers/
? ??? counterReducer.js
? ??? userReducer.js
??? store/
??? index.js
Good structure:
src/
??? features/
? ??? counter/
? ? ??? counterSlice.js
? ? ??? Counter.jsx
? ??? user/
? ??? userSlice.js
? ??? User.jsx
??? store/
??? index.js
Each feature is self-contained, with its own state, logic, and UI. This modular structure allows teams to work independently and makes code reviews and testing more straightforward.
2. Avoid Deeply Nested State
While Redux allows any data structure, deeply nested state makes selectors and reducers harder to write and debug. Normalize your state when dealing with relational data.
For example, instead of:
{
posts: [
{
id: 1,
title: 'Post 1',
author: {
id: 10,
name: 'John',
posts: [1, 2]
}
}
]
}
Use normalized state:
{
posts: {
byId: {
1: { id: 1, title: 'Post 1', authorId: 10 },
2: { id: 2, title: 'Post 2', authorId: 11 }
},
allIds: [1, 2]
},
authors: {
byId: {
10: { id: 10, name: 'John' },
11: { id: 11, name: 'Jane' }
},
allIds: [10, 11]
}
}
This approach improves performance and makes updates more predictable. Libraries like Normalizr can help automate normalization for complex data.
3. Use Selectors to Derive State
Always use selectors to extract data from the Redux store. Selectors are functions that take the state and return a computed value. They help avoid repetitive logic in components and enable memoization for performance.
Use Reselect (a library included with Redux Toolkit) to create memoized selectors:
// src/features/counter/counterSelectors.js
import { createSelector } from '@reduxjs/toolkit';
export const selectCounter = state => state.counter;
export const selectCounterValue = createSelector(
[selectCounter],
counter => counter.value
);
export const selectIsEven = createSelector(
[selectCounterValue],
value => value % 2 === 0
);
Then use them in your component:
const count = useSelector(selectCounterValue);
const isEven = useSelector(selectIsEven);
Memoized selectors only recompute when their inputs change, preventing unnecessary re-renders.
4. Keep Reducers Pure and Predictable
Reducers must be pure functions: given the same state and action, they must always return the same result. Never mutate state directly outside of Redux Toolkits Immer system. Avoid side effects like API calls, routing, or local storage writes inside reducers.
Always return a new state object. With Redux Toolkit, you can mutate the draft state safely:
// ? Correct
reducers: {
addTodo: (state, action) => {
state.todos.push(action.payload); // Immer handles immutability
}
}
But never do this:
// ? Avoid
reducers: {
addTodo: (state, action) => {
state = [...state, action.payload]; // This does nothing!
}
}
State mutations inside reducers are the most common source of bugs. Stick to the rules.
5. Use TypeScript for Type Safety
If youre using TypeScript, define types for your state, actions, and dispatch. This prevents runtime errors and improves developer experience.
// src/features/counter/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = {
value: 0,
};
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
incrementByAmount: (state, action: PayloadAction) => {
state.value += action.payload;
},
},
});
export const { increment, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
For dispatch, use the typed version:
import type { RootState, AppDispatch } from '../../store';
import { useDispatch, useSelector } from 'react-redux';
const dispatch = useDispatch();
const count = useSelector((state: RootState) => state.counter.value);
Define your store type:
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
6. Avoid Overusing Redux
Not every piece of state needs to be in Redux. Use local component state (useState, useReducer) for UI state like form inputs, modals, or toggles. Reserve Redux for global state that affects multiple components or requires persistence across routes.
Ask yourself:
- Is this state shared across multiple components?
- Does it change frequently due to user interaction or external events?
- Do I need to persist it, log it, or undo/redo changes?
If the answer is no, consider using Reacts built-in state management instead.
Tools and Resources
1. Redux DevTools Extension
Install the Redux DevTools Extension for Chrome or Firefox. It allows you to:
- Track every action dispatched
- Inspect state changes over time
- Time-travel debug by reverting to previous states
- Export/import state snapshots
Redux Toolkit automatically integrates with DevTools, so no extra configuration is needed.
2. Redux Toolkit
As mentioned earlier, @reduxjs/toolkit is the recommended way to write Redux logic. It includes:
createSliceCombines action creators and reducerscreateAsyncThunkSimplifies async logicconfigureStoreAuto-configures middleware and DevToolscreateEntityAdapterManages normalized state
It reduces boilerplate by 70% and eliminates common mistakes.
3. Redux Toolkit Query (RTK Query)
For data fetching and caching, use RTK Query, a data fetching and caching tool built into Redux Toolkit. It replaces the need for manual async thunks, loading states, and cache invalidation.
Example:
// src/features/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const apiSlice = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com' }),
endpoints: (builder) => ({
getUser: builder.query({
query: (id) => /users/${id},
}),
}),
});
export const { useGetUserQuery } = apiSlice;
Then use it in your component:
const { data, isLoading, error } = useGetUserQuery(1);
RTK Query handles caching, refetching, polling, and invalidation automatically. Its the future of data fetching in Redux applications.
4. TypeScript and ESLint
Use TypeScript to enforce type safety. Combine it with ESLint and the eslint-plugin-redux-saga or eslint-plugin-redux for linting rules specific to Redux patterns.
Install:
npm install -D eslint eslint-plugin-react eslint-plugin-react-hooks @typescript-eslint/parser @typescript-eslint/eslint-plugin
Configure .eslintrc.js:
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['react', 'react-hooks', '@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
settings: {
react: {
version: 'detect',
},
},
};
5. Learning Resources
- Redux Toolkit Documentation Official, comprehensive guide
- Redux Essentials Tutorial Free official course
- Redux Toolkit YouTube Playlist By the Redux team
- Egghead.io Redux Courses In-depth video tutorials
- Redux Templates on GitHub Starter code for common patterns
Real Examples
Example 1: Shopping Cart with Redux
Imagine a product listing page with an Add to Cart button. The cart state must persist across routes and be accessible from multiple components.
Define the cart slice:
// src/features/cart/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
items: [],
totalQuantity: 0,
};
export const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addToCart: (state, action) => {
const existingItem = state.items.find(item => item.id === action.payload.id);
if (existingItem) {
existingItem.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
state.totalQuantity += 1;
},
removeFromCart: (state, action) => {
const item = state.items.find(item => item.id === action.payload);
if (item.quantity === 1) {
state.items = state.items.filter(item => item.id !== action.payload);
} else {
item.quantity -= 1;
}
state.totalQuantity -= 1;
},
clearCart: (state) => {
state.items = [];
state.totalQuantity = 0;
},
},
});
export const { addToCart, removeFromCart, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
Use it in a product component:
// src/features/product/Product.jsx
import React from 'react';
import { useDispatch } from 'react-redux';
import { addToCart } from '../cart/cartSlice';
const Product = ({ product }) => {
const dispatch = useDispatch();
return (
<div>
<h4>{product.name}</h4>
<p>${product.price}</p>
<button onClick={() => dispatch(addToCart(product))}>Add to Cart</button>
</div>
);
};
export default Product;
And display the cart summary in the header:
// src/features/cart/CartSummary.jsx
import React from 'react';
import { useSelector } from 'react-redux';
const CartSummary = () => {
const totalQuantity = useSelector(state => state.cart.totalQuantity);
return (
<div>
<span>Cart ({totalQuantity})</span>
</div>
);
};
export default CartSummary;
This pattern scales easily: you can add checkout, discounts, or persistence with localStorage without changing the core logic.
Example 2: Authentication Flow
Managing user authentication state is a classic Redux use case. Lets build a simple auth slice:
// src/features/auth/authSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';
export const login = createAsyncThunk(
'auth/login',
async ({ email, password }, { rejectWithValue }) => {
try {
const response = await axios.post('/api/login', { email, password });
localStorage.setItem('token', response.data.token);
return response.data.user;
} catch (error) {
return rejectWithValue(error.response?.data?.message || 'Login failed');
}
}
);
export const logout = createAsyncThunk('auth/logout', () => {
localStorage.removeItem('token');
});
const initialState = {
user: null,
token: localStorage.getItem('token') || null,
loading: false,
error: null,
};
export const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
.addCase(login.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(login.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload;
})
.addCase(login.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
})
.addCase(logout.fulfilled, (state) => {
state.user = null;
state.token = null;
});
},
});
export const { clearError } = authSlice.actions;
export default authSlice.reducer;
Use it in a login form:
// src/features/auth/LoginForm.jsx
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { login } from './authSlice';
const LoginForm = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const dispatch = useDispatch();
const { loading, error } = useSelector(state => state.auth);
const handleSubmit = async (e) => {
e.preventDefault();
dispatch(login({ email, password }));
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</form>
);
};
export default LoginForm;
This structure allows you to protect routes based on authentication state and display user info globally.
FAQs
Is Redux still relevant in 2024?
Yes. While newer libraries like Zustand and Jotai offer simpler alternatives, Redux remains the most mature, well-documented, and widely supported state management solution. Its ecosystem, tooling, and community make it ideal for enterprise applications. Redux Toolkit has modernized the library significantly, reducing boilerplate and improving developer experience.
Can I use Redux without React?
Absolutely. Redux is framework-agnostic. You can use it with Vue, Angular, Svelte, or even vanilla JavaScript. The react-redux package is just a binding layer. The core Redux library works with any UI framework.
Do I need to use TypeScript with Redux?
No, but its highly recommended. TypeScript catches bugs early, improves code documentation, and enhances IDE support. With Redux Toolkit, typing your state and actions is straightforward and adds significant value.
Whats the difference between Redux and Context API?
Context API is for passing props down the component tree without prop drilling. Redux is a full state management system with middleware, devtools, and predictable state updates. Context is great for theme or language settings. Redux is better for complex, shared state with side effects.
How do I persist Redux state between sessions?
Use libraries like redux-persist to automatically save state to localStorage or sessionStorage. Configure it to persist specific slices (e.g., user auth, cart) and rehydrate them on app load.
Can I have multiple Redux stores?
Technically yes, but its strongly discouraged. Redux is designed around a single source of truth. Multiple stores make state sharing harder and defeat the purpose of centralization. Combine slices instead.
How do I test Redux logic?
Test reducers as pure functions with snapshots. Test async thunks by mocking the API and asserting dispatched actions. Use Jest and Redux Toolkits test utilities for isolated, reliable tests.
Conclusion
Implementing Redux correctly transforms how you manage state in complex applications. By following the principles of a single source of truth, immutable updates, and pure reducers, you create applications that are easier to debug, test, and scale. Redux Toolkit has made this process significantly simpler, removing much of the boilerplate that once discouraged developers from adopting Redux.
This guide walked you through setting up a store, creating slices, handling async logic, connecting components, and applying best practices. Youve seen real-world examples like shopping carts and authentication flowspatterns you can adapt to your own projects.
Remember: Redux is not always the answer. Use it when you need predictable, global state with complex interactions. For simpler needs, Reacts built-in tools are often sufficient. But when your app grows beyond a few components, Redux provides the structure and tooling to keep your codebase maintainable and robust.
As you continue building, explore RTK Query for data fetching and immer for advanced state manipulation. Stay updated with the official Redux documentation and community resources. With practice, youll master Redux and leverage it to build applications that are not only powerfulbut also elegant and scalable.