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.
{
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 :
{ 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 :
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 :
npm install @reduxjs/toolkit react-redux
Créer un Slice
Un slice regroupe l'état, les reducers et les actions pour une fonctionnalité :
// 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
// 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
// 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
// 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
// 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
// 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>
);
}
// 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 :
// 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;
// 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 :
// 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 ->