Table of Contents
- What is Redux?
- When Do You Need Redux?
- Core Concepts: Store, Actions, Reducers
- How Redux Data Flow Works
- Creating Your First Store
- Action Creators
- Reducers In Depth
- Connecting Redux to React
- Redux Toolkit (RTK)
- Slices: The Modern Way
- Async Logic with createAsyncThunk
- RTK Query: API Made Easy
- Middleware in Redux
- Redux DevTools
- Real-World Project: Shopping Cart
- Best Practices
- Redux vs Context API
- FAQ
1. What is Redux?
Redux is a predictable state container for JavaScript applications. It helps you manage the entire state of your app in a single, centralized store — making state changes predictable, traceable, and debuggable.
The Three Principles of Redux
- Single Source of Truth: The entire application state is stored in one object tree within a single store
- State is Read-Only: The only way to change state is to dispatch an action — an object describing what happened
- Changes via Pure Functions: Reducers are pure functions that take the previous state and an action, and return the next state
// The Redux store holds the entire app state as a plain JavaScript object
const initialState = {
user: { name: 'Avinash', email: 'avi@example.com' },
todos: [
{ id: 1, text: 'Learn Redux', completed: false },
{ id: 2, text: 'Build an app', completed: false }
],
cart: { items: [], total: 0 }
};
Think of it this way: Without Redux, each component manages its own state. With Redux, there's one central brain that every component can read from and write to — no prop drilling needed.
2. When Do You Need Redux?
Redux is powerful, but it's not always necessary. Here's when Redux shines:
Use Redux When:
- Multiple components need access to the same state
- State updates are complex with many sub-values
- You need middleware (logging, async, analytics)
- You want time-travel debugging with Redux DevTools
- The app is large with many features and teams
- You need predictable state transitions for testing
Don't Use Redux When:
- The app is small with simple state (use useState/useContext)
- State is only needed in one or two components
- You're adding it just because "everyone uses it"
Rule of thumb: Start with React's built-in state tools (useState, useContext, useReducer). When you find yourself passing props through 3+ levels or managing the same state in multiple distant components, consider Redux.
3. Core Concepts: Store, Actions, Reducers
Redux has three core building blocks. Understanding them is essential before writing any code.
Store
The store is the single source of truth. It holds the entire application state tree and provides methods to read, update, and subscribe to state changes.
// The store has three main methods:
// 1. store.getState() — Read current state
// 2. store.dispatch() — Send an action to update state
// 3. store.subscribe() — Listen for state changes
Actions
Actions are plain JavaScript objects that describe what happened. Every action must have a type property. They can also carry a payload with data.
// Action: a plain object with a type (and optional payload)
const addTodoAction = {
type: 'todos/addTodo',
payload: { id: 3, text: 'Learn Redux Toolkit', completed: false }
};
const toggleTodoAction = {
type: 'todos/toggleTodo',
payload: 1 // the todo id to toggle
};
const removeTodoAction = {
type: 'todos/removeTodo',
payload: 2 // the todo id to remove
};
Reducers
Reducers are pure functions that determine how state changes. They take the current state and an action, and return the new state.
// Reducer: pure function (no side effects, no API calls)
// (previousState, action) => newState
function todosReducer(state = [], action) {
switch (action.type) {
case 'todos/addTodo':
return [...state, action.payload];
case 'todos/toggleTodo':
return state.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
);
case 'todos/removeTodo':
return state.filter(todo => todo.id !== action.payload);
default:
return state; // Always return current state for unknown actions
}
}
Reducer rules — never forget:
- Never mutate the state directly — always return a new object/array
- Never do async operations (API calls, timers) inside a reducer
- Never use non-serializable values (Date objects, class instances) in state
4. How Redux Data Flow Works
Redux follows a strict unidirectional data flow. Here's the lifecycle of every state change:
// The Redux Data Flow Cycle:
// 1. UI Event occurs (button click, form submit, etc.)
// ↓
// 2. dispatch(action) is called with an action object
// ↓
// 3. Reducer receives (currentState, action) → returns new state
// ↓
// 4. Store updates with the new state
// ↓
// 5. Subscribed components re-render with new state
// Visual representation:
//
// ┌─────────────────────────────────────────────────┐
// │ Redux Store │
// │ { todos: [...], user: {...} } │
// └─────────┬───────────────────────┬───────────────┘
// │ │
// getState() dispatch(action)
// │ │
// ▼ │
// ┌──────────────────┐ │
// │ UI Component │──────────────┘
// │ reads state │
// └──────────────────┘
// │
// ▼
// User interacts (click, type, etc.)
// │
// ▼
// dispatch({ type: 'ACTION', payload: data })
Why unidirectional? When data flows in one direction, it's easy to trace bugs. You always know where state came from and how it changed — unlike two-way binding where changes can cascade unpredictably.
5. Creating Your First Store
Let's build a Redux store from scratch to understand how everything connects.
// Install dependencies
// npm install redux react-redux
// ============================================
// src/store/store.js — Create the store
// ============================================
import { createStore } from 'redux';
import todosReducer from './todosReducer';
// Create store with reducer
const store = createStore(
todosReducer,
// Enable Redux DevTools (optional)
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);
export default store;
// ============================================
// src/store/todosReducer.js — Define reducer
// ============================================
const initialState = [];
export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/addTodo':
return [...state, action.payload];
case 'todos/toggleTodo':
return state.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
);
case 'todos/removeTodo':
return state.filter(todo => todo.id !== action.payload);
default:
return state;
}
}
// ============================================
// src/index.js — Provide store to app
// ============================================
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './store/store';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
);
6. Action Creators
Action creators are functions that return action objects. They keep your action logic consistent and reusable.
// ============================================
// src/store/actions/todoActions.js
// ============================================
// Simple action creators
export const addTodo = (text) => ({
type: 'todos/addTodo',
payload: {
id: Date.now(),
text,
completed: false,
}
});
export const toggleTodo = (id) => ({
type: 'todos/toggleTodo',
payload: id
});
export const removeTodo = (id) => ({
type: 'todos/removeTodo',
payload: id
});
// ============================================
// Usage in a component
// ============================================
import { useDispatch } from 'react-redux';
import { addTodo, toggleTodo, removeTodo } from './store/actions/todoActions';
function TodoInput() {
const dispatch = useDispatch();
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
dispatch(addTodo(text.trim())); // Dispatch the action creator
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a todo..."
/>
<button type="submit">Add</button>
</form>
);
}
7. Reducers In Depth
Reducers are where the actual state logic lives. Let's explore patterns for writing clean, maintainable reducers.
Multiple State Slices
// ============================================
// src/store/reducers/userReducer.js
// ============================================
const userInitialState = {
data: null,
loading: false,
error: null,
};
export default function userReducer(state = userInitialState, action) {
switch (action.type) {
case 'user/fetchStart':
return { ...state, loading: true, error: null };
case 'user/fetchSuccess':
return { ...state, loading: false, data: action.payload };
case 'user/fetchError':
return { ...state, loading: false, error: action.payload };
case 'user/logout':
return userInitialState;
default:
return state;
}
}
// ============================================
// src/store/reducers/cartReducer.js
// ============================================
const cartInitialState = {
items: [],
total: 0,
};
export default function cartReducer(state = cartInitialState, action) {
switch (action.type) {
case 'cart/addItem': {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }],
};
}
case 'cart/removeItem':
return {
...state,
items: state.items.filter(i => i.id !== action.payload),
};
default:
return state;
}
}
// ============================================
// Combine reducers into root reducer
// ============================================
import { combineReducers } from 'redux';
import todosReducer from './todosReducer';
import userReducer from './userReducer';
import cartReducer from './cartReducer';
const rootReducer = combineReducers({
todos: todosReducer, // state.todos
user: userReducer, // state.user
cart: cartReducer, // state.cart
});
export default rootReducer;
8. Connecting Redux to React
React Redux provides hooks to read state from and dispatch actions to the Redux store.
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, toggleTodo, removeTodo } from '../store/actions/todoActions';
function TodoList() {
// useSelector: read state from the store
const todos = useSelector(state => state.todos);
// useDispatch: get the dispatch function
const dispatch = useDispatch();
const completedCount = todos.filter(t => t.completed).length;
return (
<div>
<h2>Todos ({completedCount}/{todos.length} completed)</h2>
{todos.map(todo => (
<div key={todo.id} className="todo-item">
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch(toggleTodo(todo.id))}
/>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.text}
</span>
<button onClick={() => dispatch(removeTodo(todo.id))}>
✕
</button>
</div>
))}
</div>
);
}
// ============================================
// useSelector with shallow comparison
// ============================================
import { shallowEqual } from 'react-redux';
// For objects/arrays, use shallowEqual to prevent unnecessary re-renders
const cartItems = useSelector(
state => ({ items: state.cart.items, total: state.cart.total }),
shallowEqual
);
// ============================================
// Conditional selectors
// ============================================
const userName = useSelector(state => state.user.data?.name ?? 'Guest');
const isLoading = useSelector(state => state.user.loading);
9. Redux Toolkit (RTK) — The Modern Way
Redux Toolkit is the official, recommended way to write Redux logic. It eliminates boilerplate, includes Immer for immutable updates, and provides powerful utilities.
// Install
// npm install @reduxjs/toolkit react-redux
// Why Redux Toolkit?
// ✅ Less boilerplate — no switch statements, no spread operators
// ✅ Immer integration — write "mutating" code that's actually immutable
// ✅ Built-in createSlice, createAsyncThunk, configureStore
// ✅ Redux DevTools configured out of the box
// ✅ TypeScript support included
Before vs After Redux Toolkit
| Concept | Classic Redux | Redux Toolkit |
|---|---|---|
| Store setup | createStore(rootReducer, enhancers) |
configureStore({ reducer }) |
| Reducers | Separate files with switch statements | Single createSlice file |
| Immutable updates | Manual spread operators | Immer (write mutable-style code) |
| Async logic | redux-thunk + manual dispatch | createAsyncThunk built-in |
| Lines of code | ~60 lines per feature | ~20 lines per feature |
10. Slices: The Modern Way
A "slice" is a collection of Redux reducer logic and actions for a single feature. createSlice generates action creators and action types automatically.
// ============================================
// src/features/todos/todoSlice.js
// ============================================
import { createSlice } from '@reduxjs/toolkit';
const todoSlice = createSlice({
name: 'todos', // prefix for action types
initialState: [], // initial state
reducers: {
// Immer allows "mutating" code — it's actually immutable!
addTodo: (state, action) => {
state.push({
id: Date.now(),
text: action.payload,
completed: false,
});
},
toggleTodo: (state, action) => {
const todo = state.find(t => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed; // Immer handles immutability
}
},
removeTodo: (state, action) => {
return state.filter(t => t.id !== action.payload);
},
},
});
// Export actions (auto-generated!)
export const { addTodo, toggleTodo, removeTodo } = todoSlice.actions;
// Export reducer
export default todoSlice.reducer;
// ============================================
// src/features/cart/cartSlice.js
// ============================================
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
total: 0,
},
reducers: {
addItem: (state, action) => {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
},
removeItem: (state, action) => {
state.items = state.items.filter(i => i.id !== action.payload);
state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
},
clearCart: (state) => {
state.items = [];
state.total = 0;
},
},
});
export const { addItem, removeItem, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
Using Slices in Components
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, toggleTodo, removeTodo } from '../features/todos/todoSlice';
function TodoApp() {
const todos = useSelector(state => state.todos);
const dispatch = useDispatch();
const [input, setInput] = useState('');
return (
<div>
<form onSubmit={(e) => {
e.preventDefault();
if (input.trim()) {
dispatch(addTodo(input.trim()));
setInput('');
}
}}>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit">Add Todo</button>
</form>
{todos.map(todo => (
<div key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch(toggleTodo(todo.id))}
/>
<span>{todo.text}</span>
<button onClick={() => dispatch(removeTodo(todo.id))}>✕</button>
</div>
))}
</div>
);
}
11. Async Logic with createAsyncThunk
Real apps need to fetch data from APIs. createAsyncThunk handles async operations with pending/fulfilled/rejected states.
// ============================================
// src/features/users/usersSlice.js
// ============================================
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// 1. Create async thunk
export const fetchUsers = createAsyncThunk(
'users/fetchUsers', // action type prefix
async (_, { rejectWithValue }) => {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) throw new Error('Failed to fetch users');
const data = await response.json();
return data; // This becomes the action.payload on success
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 2. Create slice with extraReducers for async actions
const usersSlice = createSlice({
name: 'users',
initialState: {
data: [],
loading: false,
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
},
});
export default usersSlice.reducer;
// 3. Use in component
function UserList() {
const dispatch = useDispatch();
const { data: users, loading, error } = useSelector(state => state.users);
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
if (loading) return <Spinner />;
if (error) return <div className="error">{error}</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
);
}
Action Lifecycle
| Stage | Action Type | State Change |
|---|---|---|
| Pending | users/fetchUsers/pending |
loading: true, error: null |
| Fulfilled | users/fetchUsers/fulfilled |
loading: false, data: [...] |
| Rejected | users/fetchUsers/rejected |
loading: false, error: "..." |
12. RTK Query: API Made Easy
RTK Query is a data fetching and caching tool built into Redux Toolkit. It eliminates manual loading states, caching, and refetching logic.
// ============================================
// src/services/api.js
// ============================================
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com' }),
tagTypes: ['Post', 'User'],
endpoints: (builder) => ({
// GET /posts
getPosts: builder.query({
query: () => '/posts',
providesTags: ['Post'],
}),
// GET /posts/:id
getPostById: builder.query({
query: (id) => `/posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
// POST /posts
createPost: builder.mutation({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
invalidatesTags: ['Post'], // Refetch posts after creation
}),
// DELETE /posts/:id
deletePost: builder.mutation({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
invalidatesTags: ['Post'],
}),
}),
});
// Auto-generated hooks!
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useCreatePostMutation,
useDeletePostMutation,
} = apiSlice;
// ============================================
// Add to store
// ============================================
import { configureStore } from '@reduxjs/toolkit';
import { apiSlice } from '../services/api';
export const store = configureStore({
reducer: {
[apiSlice.reducerPath]: apiSlice.reducer,
// ... other reducers
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(apiSlice.middleware),
});
// ============================================
// Usage in component
// ============================================
function PostList() {
const { data: posts, isLoading, error } = useGetPostsQuery();
const [createPost] = useCreatePostMutation();
if (isLoading) return <Spinner />;
if (error) return <div>Error loading posts</div>;
return (
<div>
<button onClick={() => createPost({ title: 'New', body: 'Content', userId: 1 })}>
Add Post
</button>
{posts?.map(post => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</article>
))}
</div>
);
}
RTK Query vs manual fetch: RTK Query handles caching, automatic refetching, loading/error states, polling, and optimistic updates — all with zero extra code. It's the fastest way to add API data to a Redux app.
13. Middleware in Redux
Middleware provides a third-party extension point between dispatching an action and the moment it reaches the reducer. Think of it as a pipeline for actions.
// ============================================
// Logger Middleware — tracks every action
// ============================================
const loggerMiddleware = (store) => (next) => (action) => {
console.group(`Action: ${action.type}`);
console.log('Payload:', action.payload);
console.log('Previous State:', store.getState());
const result = next(action); // Pass action to next middleware/reducer
console.log('Next State:', store.getState());
console.groupEnd();
return result;
};
// ============================================
// Configure store with middleware
// ============================================
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './features/todos/todoSlice';
import cartReducer from './features/cart/cartSlice';
const store = configureStore({
reducer: {
todos: todosReducer,
cart: cartReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(loggerMiddleware),
});
export default store;
// ============================================
// Custom async middleware
// ============================================
const apiMiddleware = (store) => (next) => (action) => {
if (action.type === 'api/fetch') {
const { url, onSuccess, onError } = action.payload;
fetch(url)
.then(res => res.json())
.then(data => store.dispatch({ type: onSuccess, payload: data }))
.catch(err => store.dispatch({ type: onError, payload: err.message }));
}
return next(action);
};
14. Redux DevTools
Redux DevTools is a browser extension that lets you inspect every action, view the state tree, and time-travel debug.
// Redux DevTools come built-in with Redux Toolkit!
// No extra configuration needed.
// For manual setup with classic Redux:
const store = createStore(
rootReducer,
window.__REDUX_DEVTOOLS_EXTENSION__ &&
window.__REDUX_DEVTOOLS_EXTENSION__({
name: 'My App',
trace: true, // include stack traces
traceLimit: 25, // max stack trace frames
})
);
// Features you get for free:
// ✅ Action timeline — see every dispatched action
// ✅ State diff — visual diff of state changes
// ✅ Time travel — step forward/backward through actions
// ✅ Action replay — re-dispatch any past action
// ✅ Action inspection — view action type, payload, and stack trace
// ✅ State tree browser — explore the entire state object
// ✅ Filter by action type
15. Real-World Project: Shopping Cart
Let's build a complete shopping cart feature using Redux Toolkit to put everything together.
// ============================================
// src/features/cart/cartSlice.js
// ============================================
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// Async: fetch products from API
export const fetchProducts = createAsyncThunk(
'cart/fetchProducts',
async () => {
const res = await fetch('https://fakestoreapi.com/products');
return await res.json();
}
);
const cartSlice = createSlice({
name: 'cart',
initialState: {
products: [], // available products
cartItems: [], // items in cart
loading: false,
error: null,
},
reducers: {
addToCart: (state, action) => {
const product = action.payload;
const existing = state.cartItems.find(i => i.id === product.id);
if (existing) {
existing.quantity += 1;
} else {
state.cartItems.push({
id: product.id,
title: product.title,
price: product.price,
image: product.image,
quantity: 1,
});
}
},
removeFromCart: (state, action) => {
state.cartItems = state.cartItems.filter(i => i.id !== action.payload);
},
updateQuantity: (state, action) => {
const { id, quantity } = action.payload;
const item = state.cartItems.find(i => i.id === id);
if (item && quantity > 0) {
item.quantity = quantity;
}
},
clearCart: (state) => {
state.cartItems = [];
},
},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchProducts.fulfilled, (state, action) => {
state.loading = false;
state.products = action.payload;
})
.addCase(fetchProducts.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});
// Selectors
export const selectCartTotal = (state) =>
state.cart.cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
export const selectCartCount = (state) =>
state.cart.cartItems.reduce((sum, item) => sum + item.quantity, 0);
export const { addToCart, removeFromCart, updateQuantity, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
// ============================================
// src/store/store.js
// ============================================
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from '../features/cart/cartSlice';
export const store = configureStore({
reducer: {
cart: cartReducer,
},
});
Cart Component
// ============================================
// src/components/ShoppingCart.jsx
// ============================================
import { useSelector, useDispatch } from 'react-redux';
import {
addToCart,
removeFromCart,
updateQuantity,
clearCart,
selectCartTotal,
selectCartCount,
} from '../features/cart/cartSlice';
function ShoppingCart() {
const cartItems = useSelector(state => state.cart.cartItems);
const total = useSelector(selectCartTotal);
const count = useSelector(selectCartCount);
const dispatch = useDispatch();
if (cartItems.length === 0) {
return <div className="empty-cart">Your cart is empty</div>;
}
return (
<div className="shopping-cart">
<h2>Shopping Cart ({count} items)</h2>
{cartItems.map(item => (
<div key={item.id} className="cart-item">
<img src={item.image} alt={item.title} width="60" />
<div className="cart-item-info">
<h4>{item.title}</h4>
<p>${item.price.toFixed(2)} each</p>
</div>
<div className="cart-item-actions">
<button onClick={() =>
dispatch(updateQuantity({ id: item.id, quantity: item.quantity - 1 }))
}>-</button>
<span>{item.quantity}</span>
<button onClick={() =>
dispatch(updateQuantity({ id: item.id, quantity: item.quantity + 1 }))
}>+</button>
<button onClick={() => dispatch(removeFromCart(item.id))}>
Remove
</button>
</div>
</div>
))}
<div className="cart-total">
<h3>Total: ${total.toFixed(2)}</h3>
<button onClick={() => dispatch(clearCart())}>Clear Cart</button>
<button className="checkout-btn">Checkout</button>
</div>
</div>
);
}
export default ShoppingCart;
16. Best Practices
Follow these patterns to keep your Redux code clean, maintainable, and performant.
File Structure
src/
├── app/
│ └── store.js # configureStore
├── features/
│ ├── todos/
│ │ ├── todoSlice.js # slice, reducers, actions
│ │ └── TodoList.jsx # component using the slice
│ ├── cart/
│ │ ├── cartSlice.js
│ │ └── ShoppingCart.jsx
│ └── users/
│ ├── usersSlice.js
│ └── UserList.jsx
├── services/
│ └── api.js # RTK Query API
└── App.jsx
Golden Rules
- Use Redux Toolkit (RTK) — never write classic Redux boilerplate
- Co-locate slices with components — keep related code together
- Use selectors for derived data — compute values in selectors, not components
- Avoid over-normalizing — only normalize if you need to update the same entity from multiple places
- Use RTK Query for API data — don't manually manage loading/error states
- Keep state minimal — don't store data in Redux that can be derived from other state
- Use TypeScript — Redux Toolkit has first-class TypeScript support
Performance Tips
- Use
shallowEqualwithuseSelectorfor object/array returns - Memoize expensive selectors with
createSelector - Only subscribe to the specific state slice you need
- Avoid putting large, frequently-changing data (like keystrokes) in Redux
Remember: Redux is a tool, not a requirement. Use it when it solves real problems in your app — not just because it's popular. Start simple, add Redux when you feel the pain of prop drilling or scattered state.
17. Redux vs React Context API
Both manage global state, but they serve different needs. Here's a detailed comparison.
| Feature | Redux Toolkit | React Context |
|---|---|---|
| Learning curve | Medium (RTK simplifies it) | Low |
| Bundle size | ~11KB (redux + react-redux) | 0KB (built into React) |
| DevTools | Excellent (time-travel, action log) | None |
| Performance | Selective subscriptions | All consumers re-render |
| Async support | RTK Query, createAsyncThunk | Manual (useEffect + state) |
| Middleware | Built-in (thunk, logger) | Not available |
| Testing | Easy to test reducers in isolation | Requires wrapper providers |
| Best for | Complex state, large apps, teams | Theme, auth, locale, small apps |
// Context: Simple, no extra dependencies
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = (credentials) => { /* ... */ };
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Redux: Powerful, feature-rich
// Use when context feels limiting — frequent updates,
// complex state logic, or need for dev tools
18. Frequently Asked Questions
Do I need Redux for a small React app?
No. For small apps, React's built-in useState and useContext are sufficient. Redux is best for medium to large apps with complex state logic, multiple components sharing state, or when you need features like time-travel debugging and middleware.
Is Redux Toolkit the same as Redux?
Redux Toolkit (RTK) is the official, recommended way to write Redux logic. It includes utilities that simplify store setup, reducers, and API calls. It uses Immer under the hood for immutable updates. Think of RTK as Redux with superpowers and less boilerplate.
What is the difference between Redux and React Context?
React Context is best for simple global state like theme or auth. Redux Toolkit is better for complex state with many updates, middleware needs, dev tools, and performance optimizations. Redux provides selective subscriptions so only affected components re-render.
Is Redux worth learning in 2026?
Yes. Redux is still widely used in enterprise applications and is a frequently asked interview topic. Redux Toolkit has modernized it significantly. Understanding Redux fundamentals also helps you grasp state management concepts applicable to other libraries.
What replaced Redux in modern React?
Nothing "replaced" Redux — it evolved. Redux Toolkit made Redux much simpler. For simpler needs, React Context + useReducer, Zustand, Jotai, or Recoil are alternatives. But Redux Toolkit remains the most mature and feature-rich option for complex apps.
Final advice: Start learning Redux with Redux Toolkit from day one. Don't bother with classic Redux boilerplate. The Toolkit way is the modern standard and what every production codebase should use.