Accueil / Articles

Redux Toolkit : de First Slice aux données asynchrones – un modèle mental opérationnel

Apprenez comment le store de Redux Toolkit, les slices, les réducteurs alimentés par Immer, les sélecteurs et createAsyncThunk s’assemblent, à l’aide d’un compteur, d’un panier d’achat et d’une liste de produits.

Éditeur : REACTAPP.TOP9 min de lecture2008 mots

Sources: Redux Toolkit: A Complete Guide from Basics to Production

Au fur et à mesure que l’application React se développe, les états partagés tels que l’utilisateur, le panier, les préférences, les filtres et les données API se répandent sur de nombreuses pages, ce qui rend fragile le transfert de tous ces éléments via des props. Redux résout ce problème grâce à un stock centralisé, et Redux Toolkit (RTK) est la méthode recommandée pour utiliser Redux aujourd’hui. Ce guide vous guide, depuis une simple fonction de comptage jusqu’à un panier avec des totaux calculés et une liste alimentée par une API, afin que vous puissiez lire et écrire du code RTK avec confiance.

Tout d’abord, vérifiez si vous avez réellement besoin d’un stock : le simple usage de props n’est pas une raison suffisante pour installer Redux.

L’idée fondamentale derrière Redux

Redux conserve les états partagés dans un seul stock et ne les modifie qu’au moyen d’actions gérées par des reducers, ce qui rend chaque mise à jour prévisible. Sans lui, les données se transmettent étage par étage via des props.

Component
   ↓
Props
   ↓
Component
   ↓
Props
   ↓
Component

Avec un store, les composants lisent ce dont ils ont besoin et envoient des actions pour demander des modifications.

             Redux Store
                  ↑
                  │
        ┌─────────┴─────────┐
        │                   │
    Component A         Component B
        │                   │
        └──── dispatch ─────┘

Ce que Redux Toolkit ajoute

RTK regroupe des outils pour les tâches courantes de Redux.

Redux Toolkit
│
├── configureStore()
├── createSlice()
├── createAsyncThunk()
├── createSelector()
└── RTK Query

Redux classique répartit une fonctionnalité sur de nombreux fichiers.

Action Types
Action Creators
Reducers
Constants
Store
Middleware

createSlice() vous permet d’exprimer la plupart de ces fonctionnalités en un seul endroit.

Comment les données circulent dans une application RTK

Les composants lisent les données avec useSelector() et envoient des actions avec useDispatch() ; les reducers génèrent un nouvel état.

                 React Application
                        │
                        ↓
                 useSelector()
                        │
                        ↓
                 Redux Store
                        ↑
                        │
                 useDispatch()
                        ↑
                        │
                    Actions
                        │
                        ↓
                    Reducer
                        │
                        ↓
                   New State

Le cycle essentiel :

UI
 ↓
dispatch(action)
 ↓
Reducer
 ↓
State update
 ↓
Subscribed components
 ↓
UI re-render

Installation et création du store

Installez deux paquets :

npm install @reduxjs/toolkit react-redux

@reduxjs/toolkit contient les API de Redux ; react-redux les relie à React.

useSelector()
useDispatch()
Provider

L’exemple d’application de compteur est organisé par fonctionnalité.

src/
│
├── app/
│   └── store.js
│
├── features/
│   └── counter/
│       └── counterSlice.js
│
├── App.jsx
└── main.jsx

configureStore() prend un dictionnaire de réducteurs de slice ; chaque clé devient une branche d’état de niveau supérieur.

import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

L’état résultant :

{
  counter: {
    value: 0
  }
}

Slices : une fonctionnalité, un fichier

Un slice représente une partie logique de l’état, et les applications en possèdent généralement plusieurs.

authSlice
cartSlice
productSlice
userSlice
notificationSlice

Le slice du compteur définit un nom, un état initial ainsi qu’un réducteur par opération, puis exporte les créateurs d’actions et le réducteur générés.

import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  value: 0,
};

const counterSlice = createSlice({
  name: "counter",

  initialState,

  reducers: {
    increment(state) {
      state.value += 1;
    },

    decrement(state) {
      state.value -= 1;
    },

    incrementByAmount(state, action) {
      state.value += action.payload;
    },
  },
});

export const {
  increment,
  decrement,
  incrementByAmount,
} = counterSlice.actions;

export default counterSlice.reducer;

Une seule appelation remplace trois parties écrites manuellement.

Action Types
     +
Action Creators
     +
Reducer

Ce que generateSlice produit

Prenons un slice minimal :

createSlice({
  name: "counter",

  initialState,

  reducers: {
    increment(state) {
      state.value += 1;
    },
  },
});

RTK combine le nom du slice et la clé du réducteur pour former un type d’action

"counter/increment"

et crée un créateur d’action.

increment()

Ainsi, dispatch(increment()) est équivalent à l’envoi de :

dispatch({
  type: "counter/increment",
});

Connexion du store à React

Dans main.jsx, Provider rend le store accessible à chaque composant qui se trouve en dessous de lui.

import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";

import App from "./App";
import { store } from "./app/store";

ReactDOM.createRoot(
  document.getElementById("root")
).render(
  <Provider store={store}>
    <App />
  </Provider>
);

Lecture avec useSelector

useSelector() sélectionne précisément la partie de l’état dont un composant a besoin.

import { useSelector } from "react-redux";

function Counter() {
  const count = useSelector(
    state => state.counter.value
  );

  return <h1>{count}</h1>;
}

comment React-Redux décide de se rérendre.

Écriture avec useDispatch

useDispatch() renvoie la fonction dispatch du store.

import { useDispatch } from "react-redux";
import { increment } from "./counterSlice";

function Counter() {
  const dispatch = useDispatch();

  return (
    <button onClick={() => dispatch(increment())}>
      Increment
    </button>
  );
}

Button Click
     ↓
dispatch(increment())
     ↓
Action
     ↓
Reducer
     ↓
State changes
     ↓
useSelector detects relevant change
     ↓
Component re-renders

Transfert de données avec payload

payload, qui provient du deuxième argument du réducteur :

incrementByAmount(state, action) {
  state.value += action.payload;
}

dispatch(incrementByAmount(10));

{
  type: "counter/incrementByAmount",
  payload: 10
}

Chaque action possède un type et éventuellement un payload.

action
│
├── type
└── payload

Pourquoi la mutation de l’état est autorisée ici

C’est une question classique lors des entretiens : ce réducteur semble modifier l’état, ce que Redux interdit.

increment(state) {
  state.value += 1;
}

RTK exécute les réducteurs via Immer, qui fournit un proxy provisoire, enregistre les modifications et génère un nouvel objet immuable.

Reducer
   ↓
Immer Proxy
   ↓
Track changes
   ↓
Create new immutable state

On obtient ainsi du code lisible sans compromettre l’immuabilité. Il faut soit modifier le draft, soit retourner une nouvelle valeur, jamais les deux en même temps, et il ne faut pas modifier l’état de cette manière en dehors des réducteurs RTK.

Une partie concernant le panier d’achat

Un panier de commerce électronique commence vide.

const initialState = {
  items: [],
};

Cette partie permet d’ajouter, de supprimer et de vider des articles ; removeItem attribue un nouveau tableau, que Immer accepte également.

import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  items: [],
};

const cartSlice = createSlice({
  name: "cart",

  initialState,

  reducers: {
    addItem(state, action) {
      state.items.push(action.payload);
    },

    removeItem(state, action) {
      state.items = state.items.filter(
        item => item.id !== action.payload
      );
    },

    clearCart(state) {
      state.items = [];
    },
  },
});

export const {
  addItem,
  removeItem,
  clearCart,
} = cartSlice.actions;

export default cartSlice.reducer;

Ajout d’un article

Étant donné un objet produit :

const product = {
  id: 101,
  name: "Laptop",
  price: 60000,
};

un gestionnaire de clic le envoie :

const dispatch = useDispatch();

const handleAddToCart = () => {
  dispatch(addItem(product));
};

et l’ensemble du produit devient la charge utile.

{
  type: "cart/addItem",

  payload: {
    id: 101,
    name: "Laptop",
    price: 60000
  }
}

Le réducteur le met ensuite dans :

state.items

Suppression d’un élément

Filtres de suppression par id :

removeItem(state, action) {
  state.items = state.items.filter(
    item => item.id !== action.payload
  );
}

Le composant ne transmet que l’id :

dispatch(removeItem(101));

donc à l’intérieur du réducteur :

action.payload === 101

Dériver des valeurs plutôt que de les stocker

Supposons que le panier contienne ces éléments :

[
  {
    price: 100,
    quantity: 2,
  },
  {
    price: 50,
    quantity: 3,
  }
]

Le total suit directement :

100 × 2 + 50 × 3
= 200 + 150
= 350

Il est tentant de stocker le total à côté des éléments :

{
  items,
  total
}

mais alors chaque réducteur doit maintenir les deux en synchronisation. Calculez-le plutôt :

const selectCartTotal = state =>
  state.cart.items.reduce(
    (total, item) =>
      total + item.price * item.quantity,
    0
  );

et lisez-le :

const total = useSelector(selectCartTotal);

Stockez la source de vérité ; dérivez tout ce qui peut être calculé à partir d’elle.

Les sélecteurs en tant que point d’accès unique

Un sélecteur est une fonction qui lit l’état.

const selectCartItems = state =>
  state.cart.items;

Les composants l’utilisent plutôt que de connaître la forme de l’état.

const items = useSelector(selectCartItems);

Sinon, ce chemin se répète partout :

state.cart.items

Avec un sélecteur, la restructuration ne concerne qu’une seule fonction.

Mémorisation avec createSelector

createSelector() cache un résultat dérivé jusqu’à ce que ses entrées changent.

import { createSelector } from "@reduxjs/toolkit";

const selectCartItems = state =>
  state.cart.items;

export const selectCartTotal = createSelector(
  [selectCartItems],

  items =>
    items.reduce(
      (total, item) =>
        total + item.price * item.quantity,
      0
    )
);

La logique est simple :

Input unchanged
      ↓
Return cached result

Input changed
      ↓
Recalculate

Cela aide pour les calculs coûteux ainsi que pour les sélecteurs qui retournent de nouveaux tableaux ou objets, ce qui entraînerait sinon un re-render à chaque mise à jour du store.

Chargement des données avec createAsyncThunk

Les applications appellent des API telles que :

GET /api/products

Une requête passe par l’état « idle », puis « loading » avant d’atteindre « success »

idle
 ↓
loading
 ↓
success

ou « error ».

idle
 ↓
loading
 ↓
error

createAsyncThunk() modélise cela : on lui donne un préfixe de type et une fonction asynchrone ; le fait de lancer une exception indique que la requête a échoué.

import {
  createAsyncThunk,
  createSlice,
} from "@reduxjs/toolkit";

export const fetchProducts = createAsyncThunk(
  "products/fetchProducts",

  async () => {
    const response = await fetch("/api/products");

    if (!response.ok) {
      throw new Error("Failed to fetch products");
    }

    return response.json();
  }
);

Cela génère trois actions liées au cycle de vie :

fetchProducts.pending
fetchProducts.fulfilled
fetchProducts.rejected

Gestion du cycle de vie dans une slice

La slice des produits suit les éléments, leur statut et les erreurs, en les mettant à jour dans extraReducers.

const productsSlice = createSlice({
  name: "products",

  initialState: {
    items: [],
    status: "idle",
    error: null,
  },

  reducers: {},

  extraReducers: builder => {
    builder

      .addCase(
        fetchProducts.pending,
        state => {
          state.status = "loading";
        }
      )

      .addCase(
        fetchProducts.fulfilled,
        (state, action) => {
          state.status = "success";
          state.items = action.payload;
        }
      )

      .addCase(
        fetchProducts.rejected,
        (state, action) => {
          state.status = "failed";
          state.error =
            action.error.message;
        }
      );
  },
});

Reducers versus extraReducers

reducers définit les actions propres à la slice, avec des fonctions de création générées.

reducers: {
  addItem() {}
}

extraReducers répond aux actions définies ailleurs, comme celles provenant de createAsyncThunk() :

pending
fulfilled
rejected

La slice ne fait que les écouter.

Déploiement du thunk depuis un composant

Démarrer la requête au chargement du composant :

const dispatch = useDispatch();

useEffect(() => {
  dispatch(fetchProducts());
}, [dispatch]);

Le mode strict exécute les effets deux fois en environnement de développement, donc on s’attend à deux requêtes. Ensuite sélectionner l’état

const {
  items,
  status,
  error,
} = useSelector(
  state => state.products
);

et afficher selon le statut :

if (status === "loading") {
  return <p>Loading...</p>;
}

if (status === "failed") {
  return <p>{error}</p>;
}

return (
  <div>
    {items.map(product => (
      <div key={product.id}>
        {product.name}
      </div>
    ))}
  </div>
);

Pour des chargements de données plus lourds, RTK Query peut remplacer ce schéma.

Moyen de transmission et configureStore

dispatch() et le réducteur.

dispatch(action)
      ↓
   Middleware
      ↓
   Reducer
      ↓
 New State

Utilisations courantes :

  • Logique asynchrone
  • Journalisation
  • Analyses
  • Flux d’authentification
  • Effets secondaires
  • Interception des actions

configureStore() remplace la configuration manuelle en ajoutant un moyen de transmission par défaut (y compris des thunks) ainsi que Redux DevTools.

const store = configureStore({
  reducer: {
    auth: authReducer,
    cart: cartReducer,
    products: productsReducer,
  },
});

Points clés

  • Une slice regroupe l’état, les réducteurs et les créateurs d’actions générés pour une fonctionnalité donnée.
  • Immer rend les réducteurs de type mutation sûrs, mais uniquement à l’intérieur des réducteurs RTK.
  • Conservez un état minimal ; déduisez le reste à l’aide de sélecteurs, en mémorisant les résultats lorsque cela est coûteux.
  • createAsyncThunk() combiné à extraReducers permet de gérer les cas de chargement, de succès et d’échec.
  • Les paramètres par défaut de configureStore() couvrent la plupart des applications.