Table des matières

Chapitre 3 : Redux et Redux Toolkit

Pourquoi Redux ?

Context est bien pour des données simples (thème, utilisateur). Mais pour des applications complexes :

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
  1. Un événement déclenche une action
  2. Le reducer calcule le nouvel état
  3. Le store notifie les composants
  4. 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

  1. Store unique : une seule source de vérité
  2. Actions : décrivent ce qui s'est passé
  3. Reducers : calculent le nouvel état
  4. Redux Toolkit : simplifie Redux avec createSlice
  5. useSelector : lire l'état
  6. useDispatch : envoyer des actions
  7. createAsyncThunk : actions asynchrones

← Chapitre précédent | Retour au module | Module suivant : Architecture SmartMaker →