Journal

Values, Targets et Outlets : ce que Stimulus fait vraiment sous le capot

Journal du mardi 18 août 2026, généré le 23 août à 18:56

Le cycle de vie d'un contrôleur

Un contrôleur Stimulus n'est pas juste un objet avec des méthodes connect() et disconnect(). Le MutationObserver interne détecte l'ajout ou le retrait de l'attribut data-controller, mais aussi les changements sur les targets et les values. Chaque valeur déclarée dans static values génère automatiquement un callback nomValueChanged(nouvelleValeur, ancienneValeur), appelé une première fois à la connexion. C'est ce mécanisme, plus que connect(), qui doit porter la logique d'initialisation dépendante d'un état.

Exemple : un compteur réactif aux valeurs

// counter_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["output"]
  static values = { count: { type: Number, default: 0 } }

  countValueChanged(value) {
    this.outputTarget.textContent = value
  }

  increment() {
    this.countValue++
  }
}

Ici, countValueChanged synchronise le DOM à chaque mutation de l'attribut data-counter-count-value, y compris si un autre script ou un autre contrôleur modifie cet attribut directement. C'est la base d'une architecture où l'état vit dans le DOM, pas dans des variables JS éphémères.

Les Outlets : coupler sans importer

Avant Stimulus 3, faire communiquer deux contrôleurs obligeait souvent à passer par des événements custom ou à interroger le DOM manuellement. Les outlets résolvent ce problème proprement :

static outlets = ["counter"]

reset() {
  this.counterOutlets.forEach(c => c.countValue = 0)
}

Déclarer data-controller="parent" avec data-parent-counter-outlet pointant vers un sélecteur CSS suffit : Stimulus résout la référence, gère la connexion/déconnexion dynamique, et expose des callbacks counterOutletConnected(controller, element) symétriques au cycle de vie du contrôleur ciblé.

Pièges fréquents à la relecture

  • Actions avec options inline : la syntaxe data-action="click->controller#method:prevent" évite un event.preventDefault() manuel, souvent oublié dans les revues de code.
  • Targets multiples : préférer xxxTargets (pluriel) à une boucle manuelle sur querySelectorAll, qui casse l'encapsulation du contrôleur.
  • Classes API (static classes) : permet de nommer les classes CSS appliquées côté HTML plutôt que de les coder en dur dans le JS, ce qui facilite le theming sans toucher au contrôleur.

Avec Turbo, ces mécanismes prennent tout leur sens : un contrôleur doit rester idempotent face à des reconnections répétées lors des navigations morphing, d'où l'intérêt de tout piloter par values plutôt que par état interne.