Design Pattern
Patterns front-end

Flux

Flux de données unidirectionnel dans les SPAs

Problème

Dans une SPA avec beaucoup d'état partagé, les mutations viennent de partout : composants, effets, websockets, callbacks. Tracer la cause d'un bug d'état devient difficile quand n'importe qui peut modifier n'importe quoi.

Le principe

Flux impose un flux de données unidirectionnel :

Action → Dispatcher → Store → View → Action → ...
  • Action : description de ce qui s'est passé ({ type: 'ADD_TO_CART', payload: item })
  • Dispatcher : achemine l'action vers le bon store
  • Store : contient l'état et la logique de mise à jour
  • View : lit le store et émet des actions en réponse aux interactions

L'état ne se modifie que via des actions. La vue ne modifie jamais l'état directement.

Redux : l'implémentation historique

Redux est l'implémentation Flux la plus utilisée. Elle ajoute la contrainte que le store est une fonction pure (reducer).

// Action
const addToCart = (item: CartItem) => ({
  type: 'cart/add' as const,
  payload: item,
});

// Reducer (fonction pure : même input = même output)
function cartReducer(state: CartState = { items: [] }, action: CartAction): CartState {
  switch (action.type) {
    case 'cart/add':
      return { items: [...state.items, action.payload] };
    case 'cart/remove':
      return { items: state.items.filter(i => i.id !== action.payload) };
    default:
      return state;
  }
}

Chaque mutation est traçable : les DevTools Redux permettent de rejouer l'historique des actions une par une.

useReducer : Flux natif en React

useReducer est Flux sans librairie externe. Adapté quand l'état est local à un composant ou un sous-arbre.

type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'reset' };

function counterReducer(state: number, action: Action): number {
  switch (action.type) {
    case 'increment': return state + 1;
    case 'decrement': return state - 1;
    case 'reset':     return 0;
  }
}

function Counter() {
  const [count, dispatch] = useReducer(counterReducer, 0);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
    </div>
  );
}

Zustand

Zustand garde le flux unidirectionnel sans le boilerplate de Redux. L'état et les actions sont colocalisés dans le store.

import { create } from 'zustand';

interface CartStore {
  items: CartItem[];
  add: (item: CartItem) => void;
  remove: (id: string) => void;
  clear: () => void;
}

const useCartStore = create<CartStore>((set) => ({
  items: [],
  add: (item) => set((state) => ({ items: [...state.items, item] })),
  remove: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id) })),
  clear: () => set({ items: [] }),
}));

// Les composants lisent et dispatchent via le même hook
function CartButton({ item }: { item: CartItem }) {
  const add = useCartStore((state) => state.add);
  return <button onClick={() => add(item)}>Ajouter</button>;
}

Quand utiliser quoi

SituationSolution
État local à un composantuseState
État local complexe avec plusieurs casuseReducer
État global partagé, app de taille moyenneZustand
Besoin de DevTools, time-travel debuggingRedux Toolkit