Chapter 3: Redux and Redux Toolkit
Why Redux?
Context is good for simple data (theme, user). But for complex applications:
- State shared between many components
- Complex update logic
- Need for action history (debug)
- Performance with many updates
Redux provides a structured and predictable solution.
Fundamental Concepts
1. Store
The store is the single source of truth. All application state is in one object.
{
user: { id: 1, name: 'Jean' },
products: [...],
cart: { items: [], total: 0 },
ui: { isLoading: false, error: null }
}
2. Actions
Actions describe what happened. They are objects with a type:
{ type: 'cart/addItem', payload: { id: 1, name: 'Product', price: 29.99 } }
{ type: 'user/login', payload: { id: 1, name: 'Jean' } }
{ type: 'ui/setLoading', payload: true }
3. Reducers
Reducers specify how the state changes in response to an action:
function cartReducer(state, action) {
switch (action.type) {
case 'cart/addItem':
return {
...state,
items: [...state.items, action.payload]
};
case 'cart/removeItem':
return {
...state,
items: state.items.filter(item => item.id !== action.payload)
};
default:
return state;
}
}
The Redux Flow
Action -> Reducer -> New State -> UI updated
- An event triggers an action
- The reducer calculates the new state
- The store notifies components
- Components re-render
Redux Toolkit: Redux Simplified
Classic Redux requires a lot of code. Redux Toolkit (RTK) simplifies everything:
npm install @reduxjs/toolkit react-redux
Create a Slice
A slice groups state, reducers and actions for a feature:
// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0
},
reducers: {
increment: (state) => {
// RTK uses Immer: you can "mutate" directly
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
}
}
});
// Export actions
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// Export reducer
export default counterSlice.reducer;
Configure the Store
// app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer
}
});
Connect React to the Store
// index.js or App.js
import { Provider } from 'react-redux';
import { store } from './app/store';
function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
);
}
Use Redux in a Component
// features/counter/Counter.js
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount } from './counterSlice';
function Counter() {
// Read state
const count = useSelector(state => state.counter.value);
// Get dispatch function
const dispatch = useDispatch();
return (
<div>
<p>{count}</p>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
</div>
);
}
Complete Example: Shopping Cart
The Slice
// features/cart/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
total: 0
},
reducers: {
addItem: (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.total = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
removeItem: (state, action) => {
state.items = state.items.filter(item => item.id !== action.payload);
state.total = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
updateQuantity: (state, action) => {
const { id, quantity } = action.payload;
const item = state.items.find(item => item.id === id);
if (item) {
item.quantity = quantity;
if (item.quantity <= 0) {
state.items = state.items.filter(i => i.id !== id);
}
}
state.total = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
clearCart: (state) => {
state.items = [];
state.total = 0;
}
}
});
export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
The Components
// components/ProductCard.js
import { useDispatch } from 'react-redux';
import { addItem } from '../features/cart/cartSlice';
function ProductCard({ product }) {
const dispatch = useDispatch();
const handleAddToCart = () => {
dispatch(addItem(product));
};
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>{product.price} €</p>
<button onClick={handleAddToCart}>Add to cart</button>
</div>
);
}
// components/Cart.js
import { useSelector, useDispatch } from 'react-redux';
import { removeItem, updateQuantity, clearCart } from '../features/cart/cartSlice';
function Cart() {
const { items, total } = useSelector(state => state.cart);
const dispatch = useDispatch();
if (items.length === 0) {
return <p>Empty cart</p>;
}
return (
<div className="cart">
<h2>Cart</h2>
{items.map(item => (
<div key={item.id} className="cart-item">
<span>{item.name}</span>
<input
type="number"
value={item.quantity}
onChange={(e) => dispatch(updateQuantity({
id: item.id,
quantity: parseInt(e.target.value)
}))}
min="0"
/>
<span>{item.price * item.quantity} €</span>
<button onClick={() => dispatch(removeItem(item.id))}>
Remove
</button>
</div>
))}
<p><strong>Total: {total} €</strong></p>
<button onClick={() => dispatch(clearCart())}>Clear cart</button>
</div>
);
}
Async Actions with createAsyncThunk
For API calls:
// features/users/usersSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// Async action
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async () => {
const response = await fetch('/api/users');
return response.json();
}
);
const usersSlice = createSlice({
name: 'users',
initialState: {
items: [],
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.items = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
}
});
export default usersSlice.reducer;
// Usage
function UserList() {
const { items, loading, error } = useSelector(state => state.users);
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{items.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Selectors
Selectors are functions to extract data from the store:
// features/cart/cartSelectors.js
// Simple selector
export const selectCartItems = state => state.cart.items;
export const selectCartTotal = state => state.cart.total;
// Computed selector
export const selectCartItemCount = state =>
state.cart.items.reduce((count, item) => count + item.quantity, 0);
// Usage
function CartIcon() {
const itemCount = useSelector(selectCartItemCount);
return <span className="cart-icon">🛒 {itemCount}</span>;
}
When to Use Redux vs Context?
| Criteria | Context | Redux |
|---|---|---|
| Simple global data (theme, user) | ✅ | ❌ Overkill |
| Shared state between distant components | ✅ | ✅ |
| Complex update logic | ❌ | ✅ |
| Async actions | Manual | ✅ createAsyncThunk |
| DevTools (time-travel debug) | ❌ | ✅ |
| Large application | ❌ Performance | ✅ |
Recommended Structure
src/
app/
store.js
features/
cart/
cartSlice.js
cartSelectors.js
Cart.js
CartIcon.js
users/
usersSlice.js
UserList.js
products/
productsSlice.js
ProductList.js
Key Points to Remember
- Single store: one single source of truth
- Actions: describe what happened
- Reducers: calculate the new state
- Redux Toolkit: simplifies Redux with createSlice
- useSelector: read state
- useDispatch: send actions
- createAsyncThunk: async actions
<- Previous Chapter | Back to module | Next Module: SmartMaker Architecture ->