Module 3Gérer l'état avec Redux
Chapitre 17 sur 18
Optimiser le state management avec Redux Toolkit
createSlice, configureStore, le CRUD des utilisateurs, createAsyncThunk et extraReducers.
- ≈ 160 min
- 6 leçons
- 5 vidéos
- 4 exercices
Module 3Gérer l'état avec Redux
Chapitre 17 sur 18
createSlice, configureStore, le CRUD des utilisateurs, createAsyncThunk et extraReducers.
Objectifs
Avec ce chapitre, tu sauras…
createSlice (name, initialState, reducers) et utiliser ses actions générées ;configureStore ;createAsyncThunk et extraReducers (pending, fulfilled, rejected).Leçon 1
Redux « classique » demande beaucoup de notions : les types d'action, les objets action, les créateurs d'action, le switch dans le reducer… et beaucoup de paquets à installer pour faire quelque chose d'utile : redux-thunk, Immer, redux-devtools…
La solution : Redux Toolkit (@reduxjs/toolkit). C'est le moyen standard d'écrire la logique Redux. Il a été créé pour répondre à trois reproches :
Redux Toolkit contient aussi RTK Query, un outil puissant de récupération et de mise en cache des données (dans des points d'entrée séparés). Il est facultatif, mais il peut éviter d'écrire soi-même la logique de chargement des données.
| Redux classique (chapitres 14 à 16) | Redux Toolkit |
|---|---|
actionTypes.js + actionCreators.js + reducer avec switch | une seule createSlice génère tout |
| copies avec spread / Immer à installer | Immer intégré : on « modifie » le state |
createStore + applyMiddleware(thunk) + composeWithDevTools | configureStore : Thunk et DevTools inclus |
redux-thunk à installer | createAsyncThunk inclus |
Vidéo · À regarder
ENRedux and Modern Redux Toolkit with Asynchronous Operation – Full Course
freeCodeCamp.org · YouTube
Leçon 2
Leçon 3
| Option | Rôle |
|---|---|
name | le nom de la slice ; les types d'action générés l'utilisent comme préfixe : counter/increment |
initialState | la valeur de départ de cette partie de l'état ; utilisée quand le reducer reçoit undefined. Ce peut être une fonction qui renvoie la valeur (utile pour lire l'état initial dans le localStorage) |
reducers | un objet de fonctions : chacune gère un type d'action (équivaut à un case du switch). Les clés deviennent les types d'action (increment → counter/increment), visibles dans Redux DevTools : donne des noms descriptifs |
Leçon 4
Vidéo · À regarder
ENRedux and Modern Redux Toolkit with Asynchronous Operation – Full Course
freeCodeCamp.org · YouTube
Leçon 5
Vidéo · À regarder
ENRedux and Modern Redux Toolkit with Asynchronous Operation – Full Course
freeCodeCamp.org · YouTube
Vidéo · À regarder
Leçon 6
1Le paquet de Redux Toolkit s'appelle…
2createSlice génère…
3Avec name: 'counter' et le reducer increment, le type d'action est…
4Pourquoi peut-on écrire state.value++ dans une slice ?
5Le store se crée avec…
6Dans configureStore({ reducer: { users: userReducer } }), l'état de la slice est dans…
7createAsyncThunk génère les actions…
8Pour traiter les actions de createAsyncThunk dans la slice, on utilise…
9Avec Redux Toolkit, Redux DevTools…
L'essentiel du chapitre
npm install @reduxjs/toolkit react-redux.createSlice({ name, initialState, reducers }) → slice.actions (créateurs d'action, types name/clé) et slice.reducer.state.value++, push) grâce à Immer ; soit modifier, soit renvoyer, pas les deux.configureStore({ reducer: { users: userReducer } }) → l'état est dans state.users ; Thunk et DevTools automatiques.useSelector(state => state.users.value), dispatch(addUser({...})) ; useRef pour lire un champ non contrôlé.createAsyncThunk('users/fetchUsers', () => promesse) → actions pending / fulfilled / rejected ; traitées dans extraReducers: (builder) => { builder.addCase(fetchUsers.pending, …) } ; erreur : action.error.message.Vidéo · À regarder
ENRedux and Modern Redux Toolkit with Asynchronous Operation – Full Course
freeCodeCamp.org · YouTube
Redux and Modern Redux Toolkit with Asynchronous Operation – Full Course
freeCodeCamp.org · YouTube
Ce que tu vas voir (en anglais, sous-titres français) : pourquoi Redux Toolkit : le formateur reprend les reproches faits à Redux (configuration, nombre de paquets, code répétitif), présente RTK comme la façon recommandée d'écrire Redux, puis l'installe et prépare la structure du projet. C'est la section 1.
npx create-react-app cours-redux-toolkit
cd cours-redux-toolkit
npm install react-redux
npm install @reduxjs/toolkit(Avec Vite : npm create vite@latest cours-redux-toolkit -- --template react, puis les deux npm install.)
import { createSlice } from '@reduxjs/toolkit'
const initialState = { value: 0 }
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment:(state)=> {
state.value++
},
decrement:(state)=> {
state.value--
},
incrementByAmount:(state, action)=> {
state.value += action.payload
},
},
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducercreateSlice est une fonction qui reçoit un état initial, un objet de fonctions reducers et un nom de slice (« tranche » de l'état). Elle génère automatiquement les créateurs d'action et les types d'action qui correspondent aux reducers. C'est l'approche standard pour écrire la logique Redux. En interne, elle utilise createAction et createReducer : on peut donc utiliser Immer pour écrire des mises à jour immuables « en mutant ».
Pour l'atelier, on fait les opérations CRUD (Create, Read, Update, Delete) sur une liste locale (projet cours-redux-toolkit-crud).
export const usersData=[
{id:1,nom:'Rami',prenom:"Ahmed"},
{id:2,nom:'Kamali',prenom:"Ali"},
{id:3,nom:'Fahmi',prenom:"Brahim"}];Le store avec configureStore
import ReactDOM from "react-dom/client";
import App from './App';
import {Provider} from 'react-redux';
import {configureStore} from '@reduxjs/toolkit'
import userReducer from './features/Users'
const root=ReactDOM.createRoot(document.getElementById("root"));
const store=configureStore({
reducer:{ users:userReducer}
})
root.render(<>
<Provider store={store}>
<App/>
</Provider>
</>)Le store est créé par configureStore, qui reçoit un objet. Sa propriété reducer est elle-même un objet : users: userReducer veut dire que l'état géré par userReducer sera dans state.users. (C'est un combineReducers automatique.)
Le reducer (la slice)
import {createSlice} from '@reduxjs/toolkit'
import {usersData} from './UsersData'
export const userSlice=createSlice({
name:"users",
initialState:{value:usersData},
reducers:{
addUser:(state,action)=>{
state.value.push(action.payload);
},
deleteUser:(state,action)=>{
state.value=state.value.filter(user=>user.id!=action.payload.id)
},
updateNom:(state,action)=>{
state.value=state.value.map(user=>{
if(user.id===action.payload.id){ return ({...user,nom:action.payload.nom}) }
else{return user;}
})
}
},
});
export const {addUser,deleteUser,updateNom}=userSlice.actions;
export default userSlice.reducer;createSlice utilise Immer : on modifie directement le state (state.value.push(...), state.value = …) et Immer s'occupe du reste.
L'interface utilisateur
import {useDispatch,useSelector} from 'react-redux';
import {useRef } from 'react'
import {addUser} from "./features/Users";
import "./App.css"
import Users from "./Users";
function App(){
const dispatch=useDispatch();
const usersList=useSelector(data=>data.users.value)
// console.log(usersList)
const nom=useRef('');
const prenom=useRef('');
function ajouter(){
const id=usersList[usersList.length-1].id;
dispatch(addUser({id:id,nom:nom.current.value,prenom:prenom.current.value}))
nom.current.value="";
prenom.current.value="";
}
return <div className="App">
<input type="text" placeholder="nom" ref={nom} />
<input type="text" placeholder="prenom" ref={prenom}/>
<button onClick={()=>{ajouter()}}>ajouter</button>
<Users/>
</div>
}
export default App;import './App.css';
import {useSelector,useDispatch} from 'react-redux';
import { deleteUser,updateNom } from './features/Users';
import {useState } from 'react';
export default function Users(){
const userList=useSelector((state)=>state.users.value)
const dispatch=useDispatch();
const [nom,setNom]=useState('')
return(<>
<div className='displayUsers'>
{userList.map((user,index)=>{
return <div key={index}>
<h1>{user.nom} {user.prenom}</h1>
<input onChange={(event)=> setNom(event.target.value)} />
<button onClick={()=>dispatch(updateNom({id:user.id,nom:nom}))} >update userName</button>
<button onClick={()=>dispatch(deleteUser({id:user.id}))}>delete </button>
</div>
})}
</div>
</>)
}.App {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
border-radius: 10px;
background-color: rgb(245, 238, 229);
}
.displayUsers { margin-top: 20px; }
.displayUsers div {
width: 400px;
height: 150px;
padding: 20px;
margin: 20px;
border-radius: 10px;
box-shadow: 0 4px 8px 0 rgba(45, 67, 35, 0.1), 0 6px 20px 0 rgba(77, 91, 41, 0.19);
text-align: center;
}useRef crée une référence vers un élément du DOM : ref={nom} sur l'input, puis nom.current.value lit ce qui est tapé. Contrairement à useState, changer une ref ne réaffiche pas le composant : c'est un champ non contrôlé (chapitre 9).
ajouter(), const id = usersList[usersList.length-1].id reprend l'id du dernier utilisateur sans l'augmenter : le nouvel utilisateur a le même id (3). Supprimer l'un supprime les deux, et les clés React sont en double. Il faut + 1 (et prévoir la liste vide) : const id = usersList.length > 0 ? usersList[usersList.length - 1].id + 1 : 1;Users, un seul état nom est partagé par tous les champs : taper dans le champ de Rami puis cliquer « update » sur Kamali renomme Kamali avec le texte de Rami. Mieux : un composant UserCard par utilisateur, avec son propre état (corrigé 17.4).key={index} devrait être key={user.id} (la liste change : chapitre 8).Ce que tu vas voir (en anglais, sous-titres français) : une application React branchée sur un store Redux Toolkit : <Provider store={store}>, lecture de la slice avec useSelector(state => state.cake.numOfCakes), envoi des actions générées avec useDispatch, et les actions cake/ordered… visibles dans Redux DevTools sans aucune configuration. C'est la section 4.
createAsyncThunk reçoit un type d'action (une chaîne, par exemple 'users/fetchUsers') et une fonction qui renvoie une promesse. Elle génère les types d'action du cycle de vie de la promesse à partir de ce préfixe, et renvoie un créateur d'action thunk qui exécute la promesse et envoie ces actions :
users/fetchUsers/pending : la requête est lancée ;users/fetchUsers/fulfilled : la requête a réussi, les données sont dans action.payload ;users/fetchUsers/rejected : la requête a échoué, l'erreur est dans action.error.
Elle ne génère pas de reducer : elle ne sait pas quelles données tu récupères ni comment suivre le chargement. C'est à toi d'écrire la logique qui traite ces actions.Le projet (cours-redux-toolkit-crud-thunk) : on charge https://jsonplaceholder.typicode.com/users. Au clic sur charger users, le message « chargement… » s'affiche, puis la liste des utilisateurs. En cas d'erreur, l'erreur s'affiche en rouge.
import {createSlice,createAsyncThunk} from '@reduxjs/toolkit'
import axios from 'axios';
const initialState={
loading:false,
users:[],
error:''
}
//elle genere pending,fulfilled et rejected action types
const fetchUsers =createAsyncThunk('users/fetchUsers', ()=>{
return axios.get('https://jsonplaceholder.typicode.com/users')
.then(response=>response.data)
})
export const userSlice=createSlice({
name:"users",
initialState:initialState,
extraReducers:(builder)=>{
builder.addCase(fetchUsers.pending,state=>{
state.loading=true
})
builder.addCase(fetchUsers.fulfilled,(state,action)=>{
state.loading=false;
state.users=action.payload
})
builder.addCase(fetchUsers.rejected,(state,action)=>{
state.loading=false;
state.users=[];
state.error=action.error
})
}
})
export {fetchUsers}
export default userSlice.reducer;Chaque reducer de slice « possède » sa tranche d'état, et plusieurs reducers de slice peuvent répondre au même type d'action. extraReducers permet à createSlice de répondre à des types d'action qu'elle n'a pas générés elle-même (ici, ceux de fetchUsers). Il utilise l'objet builder : builder.addCase(typeDAction, reducer) modifie le state selon le cycle de vie de l'action asynchrone.
import ReactDOM from "react-dom/client";
import App from './App';
import {Provider} from 'react-redux';
import {configureStore} from '@reduxjs/toolkit'
import userReducer from './features/Users'
const root=ReactDOM.createRoot(document.getElementById("root"));
const store=configureStore({
reducer:{
users:userReducer }
})
root.render(<>
<Provider store={store}>
<App/>
</Provider>
</>)import {useDispatch,useSelector} from 'react-redux';
import {fetchUsers} from "./features/Users";
function App(){
const dispatch=useDispatch();
const data=useSelector(state=>state.users.users)
const loading=useSelector(state=>state.users.loading)
const error=useSelector(state=>state.users.error)
function loadUsers(){
dispatch(fetchUsers());
}
return <div >
<h3>liste user</h3>
<button onClick={loadUsers}>charger users</button>
{loading && <h3>chargement...</h3>}
{(!loading && data.length>0) &&
<ul>
{data.map(user=><li key={user.id}>{user.name}</li>)}
</ul>}
{ (!loading && error) && <p style={{color:'red'}}>{error}</p>}
</div>
}
export default App;Avec @reduxjs/toolkit, Redux DevTools fonctionne directement, sans installation de paquet ni configuration. Mets une erreur dans l'adresse (/usersXXX) : tu vois passer users/fetchUsers/pending puis users/fetchUsers/rejected ; avec la bonne adresse, pending puis fulfilled.
action.error est un objet ({ name, message, stack… }). Le mettre dans le state puis l'afficher avec {error} provoque l'erreur React « Objects are not valid as a React child ». Garde seulement le message : state.error = action.error.message. Pense aussi à vider l'erreur dans pending (state.error = ''), sinon une ancienne erreur reste affichée.
Ce que tu vas voir (en anglais, sous-titres français) : extraReducers avec builder.addCase, puis createAsyncThunk('user/fetchUsers', …) qui appelle une API avec axios ; le formateur traite les trois cas pending, fulfilled et rejected dans la slice (loading, users, error) et regarde les actions générées dans la console. C'est la section 5.
Redux Toolkit réduit le temps de développement et la verbosité d'un code qui utilise Redux. Ce chapitre n'est pas complet : la documentation officielle de Redux Toolkit (redux-toolkit.js.org) est détaillée et présente toutes les fonctionnalités avec des exemples : RTK Query, createEntityAdapter, createSelector…
Avec createAsyncThunk, charge les posts (/posts?_limit=10) dans une slice posts (loading, items, error). Le chargement se lance automatiquement au montage du composant (useEffect + dispatch). Affiche l'erreur (message seulement) en rouge.
Réécris le compteur Redux du corrigé 14.2 (types, créateurs d'action, reducer avec switch, createStore) avec createSlice et configureStore. Ajoute un reducer reset.
Ce que createSlice renvoie :
counterSlice.actions : les créateurs d'action : increment() renvoie { type: "counter/increment" }, incrementByAmount(5) renvoie { type: "counter/incrementByAmount", payload: 5 } ;counterSlice.reducer : le reducer de la slice, à donner au store.Dans les reducers de la slice, on modifie le state (state.value++) : on dirait qu'on ne respecte plus l'immuabilité et la règle de la fonction pure. C'est pourtant totalement acceptable : Immer est intégré à Redux Toolkit. state est en réalité un brouillon (draft, chapitre 15) : Immer en fabrique un nouvel état, et l'ancien n'est jamais modifié.
Deux règles : soit on modifie le brouillon, soit on renvoie un nouvel état, pas les deux ; et cela ne marche que dans createSlice / createReducer (ailleurs, state.value++ modifierait vraiment l'état).
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './Compteur'
export const store = configureStore({
reducer: { counter: counterReducer },
})
// dans un composant :
import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement, incrementByAmount } from './Compteur'
function Counter() {
const value = useSelector((state) => state.counter.value)
const dispatch = useDispatch()
return (
<div>
<h2>{value}</h2>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
</div>
)
}Ce que tu vas voir (en anglais, sous-titres français) : le formateur écrit une slice avec createSlice (name, initialState, reducers où il modifie directement le state), exporte les actions générées et le reducer, puis crée le store avec configureStore({ reducer: { … } }) et teste les actions. C'est la section 3.
Ce que tu vas voir (en anglais, sous-titres français) : la même chose dans une application React : au montage, le composant dispatch le thunk de chargement, affiche « Loading… », puis la liste ou le message d'erreur lus avec useSelector. C'est App.js de cette section.
Crée la slice todos (initialState: []) avec les reducers ajouter(titre), basculer(id) (inverse faite) et supprimer(id), puis un composant qui affiche la liste, un champ d'ajout, une case à cocher et un bouton supprimer par tâche. Pour l'id, utilise nanoid() de @reduxjs/toolkit dans un prepare ou dans le composant.
Réalise le projet CRUD de la section 4 en corrigeant les trois points de l'encadré « Dans le support » : id incrémenté, un état par carte (UserCard), clés user.id.