Design Pattern
Comportementaux

Observer

Notifier automatiquement des objets des changements d'un autre objet

Problème

Un objet change d'état et d'autres objets doivent en être informés, sans que l'objet source connaisse ses observateurs à l'avance.

Implémentation de base

type Listener<T> = (value: T) => void;

class EventEmitter<T> {
  private listeners = new Set<Listener<T>>();

  subscribe(listener: Listener<T>): () => void {
    this.listeners.add(listener);
    // Retourne une fonction de désabonnement
    return () => this.listeners.delete(listener);
  }

  emit(value: T): void {
    this.listeners.forEach(listener => listener(value));
  }
}

// Usage
const cartUpdated = new EventEmitter<Cart>();

const unsubscribe = cartUpdated.subscribe((cart) => {
  console.log('Cart updated:', cart.items.length, 'items');
});

cartUpdated.emit(newCart); // notifie tous les abonnés

// Nettoyage
unsubscribe();

Store réactif avec Observer

type Observer<T> = (state: T) => void;

class ReactiveStore<T> {
  private observers = new Set<Observer<T>>();

  constructor(private state: T) {}

  getState(): T {
    return this.state;
  }

  setState(updater: Partial<T> | ((prev: T) => Partial<T>)): void {
    const patch = typeof updater === 'function' ? updater(this.state) : updater;
    this.state = { ...this.state, ...patch };
    this.observers.forEach(obs => obs(this.state));
  }

  subscribe(observer: Observer<T>): () => void {
    this.observers.add(observer);
    return () => this.observers.delete(observer);
  }
}

// C'est le principe de Zustand, Redux, et tous les state managers
const cartStore = new ReactiveStore({
  items: [] as CartItem[],
  total: 0,
});

cartStore.subscribe((state) => {
  document.title = `(${state.items.length}) Mon panier`;
});

Observer en React : useEffect

useEffect implémente le pattern Observer : le composant s'abonne aux changements de ses dépendances.

function CartBadge() {
  const items = useCartStore((state) => state.items);
  // useCartStore utilise subscribe de Zustand en interne
  // Le composant est un Observer du store

  return <span>{items.length}</span>;
}

Observer avec RxJS (flux asynchrones)

Pour des cas complexes (debounce, combinaison de sources), RxJS formalise le pattern Observer :

import { fromEvent, debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { from } from 'rxjs';

const searchInput = document.getElementById('search') as HTMLInputElement;

const subscription = fromEvent(searchInput, 'input')
  .pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((event) => {
      const term = (event.target as HTMLInputElement).value;
      return from(searchProducts(term));
    }),
  )
  .subscribe((results) => {
    setSearchResults(results);
  });

// Cleanup
subscription.unsubscribe();

En React : pattern pub/sub cross-composants

// EventBus singleton pour communication entre composants non liés
class EventBus {
  private emitters = new Map<string, EventEmitter<unknown>>();

  private getEmitter<T>(event: string): EventEmitter<T> {
    if (!this.emitters.has(event)) {
      this.emitters.set(event, new EventEmitter<unknown>());
    }
    return this.emitters.get(event) as EventEmitter<T>;
  }

  on<T>(event: string, listener: Listener<T>): () => void {
    return this.getEmitter<T>(event).subscribe(listener);
  }

  emit<T>(event: string, value: T): void {
    this.getEmitter<T>(event).emit(value);
  }
}

export const eventBus = new EventBus();

// Composant A émet
function AddToCartButton({ product }: { product: Product }) {
  const handleClick = () => {
    eventBus.emit('cart:item-added', { product, quantity: 1 });
  };
  return <button onClick={handleClick}>Ajouter</button>;
}

// Composant B écoute (avec cleanup)
function CartToast() {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const unsubscribe = eventBus.on<{ product: Product }>('cart:item-added', ({ product }) => {
      setMessage(`${product.name} ajouté au panier`);
      setTimeout(() => setMessage(''), 3000);
    });
    return unsubscribe; // cleanup au unmount
  }, []);

  if (!message) return null;
  return <div className="toast">{message}</div>;
}

Le retour de useEffect est exactement la fonction de désabonnement, c'est la mécanique centrale du pattern Observer en React.


Exercice : Observer dans le Pokémon Builder

Contexte

Actuellement, quand l'équipe est complète et qu'on clique sur "Ajouter", rien ne se passe visuellement, le return silencieux dans handleAdd ignore l'action sans aucun feedback :

function handleAdd(pokemon: Pokemon) {
  if (team.length >= 6) return; // ← silencieux
  // ...
}

Pour ajouter une notification, il faudrait que App.tsx gère lui-même l'UI de la notification, ce qui mélange deux responsabilités.

Objectif

Implémenter un bus d'événements (Observer) pour déclencher des notifications découplées du composant principal.

Instructions

  1. Crée le fichier src/observer/teamEvents.ts.

  2. Implémente une classe EventBus générique avec les méthodes :

    • on(event: string, callback): () => void, abonne un observateur, retourne une fonction de désabonnement
    • emit(event: string, payload?), notifie tous les abonnés
  3. Exporte une instance partagée teamEvents.

  4. Crée un composant src/components/Notification.tsx qui s'abonne à l'événement "team:full" via useEffect et affiche un message temporaire.

  5. Dans App.tsx, émets teamEvents.emit("team:full") quand on tente d'ajouter un Pokémon alors que l'équipe est pleine.

  6. Ajoute <Notification /> dans le JSX de App.tsx.

Résultat attendu

App.tsx ne sait pas qu'une notification existe. Notification ne sait pas comment l'équipe fonctionne. Les deux communiquent uniquement via les événements.