Chapitre 3 : Redux et Redux Toolkit
Pourquoi Redux ?
Context est bien pour des données simples (thème, utilisateur). Mais pour des applications complexes :
- État partagé entre beaucoup de composants
- Logique de mise à jour complexe
- Besoin d'un historique des actions (debug)
- Performance avec de nombreuses mises à jour
Redux offre une solution structurée et prévisible.
Les concepts fondamentaux
1. Store
Le store est l'unique source de vérité. Tout l'état de l'application est dans un seul objet.
- snippet.javascript
{ user: { id: 1, name: 'Jean' }, products: [...], cart: { items: [], total: 0 }, ui: { isLoading: false, error: null } }
2. Actions
Les actions décrivent ce qui s'est passé. Ce sont des objets avec un type :
- snippet.javascript
{ type: 'cart/addItem', payload: { id: 1, name: 'Produit', price: 29.99 } } { type: 'user/login', payload: { id: 1, name: 'Jean' } } { type: 'ui/setLoading', payload: true }
3. Reducers
Les reducers spécifient comment l'état change en réponse à une action :
- snippet.javascript
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; } }
Le flux Redux
Action → Reducer → Nouveau State → UI mise à jour
- Un événement déclenche une action
- Le reducer calcule le nouvel état
- Le store notifie les composants
- Les composants se re-rendent
Redux Toolkit : Redux simplifié
Redux classique nécessite beaucoup de code. Redux Toolkit (RTK) simplifie tout :
- snippet.bash
npm install @reduxjs/toolkit react-redux
Créer un Slice
Un slice regroupe l'état, les reducers et les actions pour une fonctionnalité :
- snippet.javascript
// features/counter/counterSlice.js import { createSlice } from '@reduxjs/toolkit'; const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { increment: (state) => { // RTK utilise Immer : on peut "muter" directement state.value += 1; }, decrement: (state) => { state.value -= 1; }, incrementByAmount: (state, action) => { state.value += action.payload; } } }); // Export des actions export const { increment, decrement, incrementByAmount } = counterSlice.actions; // Export du reducer export default counterSlice.reducer;
Configurer le Store
- snippet.javascript
// app/store.js import { configureStore } from '@reduxjs/toolkit'; import counterReducer from '../features/counter/counterSlice'; export const store = configureStore({ reducer: { counter: counterReducer } });
Connecter React au Store
- snippet.javascript
// index.js ou App.js import { Provider } from 'react-redux'; import { store } from './app/store'; function App() { return ( <Provider store={store}> <Counter /> </Provider> ); }
Utiliser Redux dans un composant
- snippet.javascript
// features/counter/Counter.js import { useSelector, useDispatch } from 'react-redux'; import { increment, decrement, incrementByAmount } from './counterSlice'; function Counter() { // Lire l'état const count = useSelector(state => state.counter.value); // Obtenir la fonction dispatch 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> ); }
Exemple complet : Panier d'achat
Le Slice
- snippet.javascript
// 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;
Les composants
- snippet.javascript
// 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}>Ajouter au panier</button> </div> ); }
- snippet.javascript
// 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>Panier vide</p>; } return ( <div className="cart"> <h2>Panier</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))}> Supprimer </button> </div> ))} <p><strong>Total : {total} €</strong></p> <button onClick={() => dispatch(clearCart())}>Vider le panier</button> </div> ); }
Actions asynchrones avec createAsyncThunk
Pour les appels API :
- snippet.javascript
// features/users/usersSlice.js import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; // Action asynchrone 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;
- snippet.javascript
// Utilisation function UserList() { const { items, loading, error } = useSelector(state => state.users); const dispatch = useDispatch(); useEffect(() => { dispatch(fetchUsers()); }, [dispatch]); if (loading) return <p>Chargement...</p>; if (error) return <p>Erreur : {error}</p>; return ( <ul> {items.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ); }
Selectors
Les selectors sont des fonctions pour extraire des données du store :
- snippet.javascript
// features/cart/cartSelectors.js // Selector simple export const selectCartItems = state => state.cart.items; export const selectCartTotal = state => state.cart.total; // Selector calculé export const selectCartItemCount = state => state.cart.items.reduce((count, item) => count + item.quantity, 0); // Utilisation function CartIcon() { const itemCount = useSelector(selectCartItemCount); return <span className="cart-icon">🛒 {itemCount}</span>; }
Quand utiliser Redux vs Context ?
| Critère | Context | Redux |
|---|---|---|
| Données globales simples (thème, user) | ✅ | ❌ Overkill |
| État partagé entre composants éloignés | ✅ | ✅ |
| Logique de mise à jour complexe | ❌ | ✅ |
| Actions asynchrones | Manuel | ✅ createAsyncThunk |
| DevTools (time-travel debug) | ❌ | ✅ |
| Large application | ❌ Performance | ✅ |
Structure recommandée
src/
app/
store.js
features/
cart/
cartSlice.js
cartSelectors.js
Cart.js
CartIcon.js
users/
usersSlice.js
UserList.js
products/
productsSlice.js
ProductList.js
Points clés à retenir
- Store unique : une seule source de vérité
- Actions : décrivent ce qui s'est passé
- Reducers : calculent le nouvel état
- Redux Toolkit : simplifie Redux avec createSlice
- useSelector : lire l'état
- useDispatch : envoyer des actions
- createAsyncThunk : actions asynchrones
← Chapitre précédent | Retour au module | Module suivant : Architecture SmartMaker →