planeta jupiter planeta tierra

Stimulus: el JavaScript que ya viene con Symfony

· 8 min de lectura · Symfony

Ilustración de dos cuadernos con el logotipo de Stimulus
nave extraterrestre
Ilustración de dos cuadernos con el logotipo de Stimulus

Necesitas un desplegable, un contador de caracteres y un botón de copiar. No hace falta montar Vue, ni un bundler, ni convertir tu aplicación en una SPA. Symfony ya trae Stimulus: unos cuantos atributos en el HTML que renderizas con Twig y una clase JavaScript pequeña. Nada más.

Qué es Stimulus (y qué no)

Stimulus es un framework minúsculo que parte de una idea distinta a la de Vue o React: el HTML lo genera el servidor y JavaScript solo se encarga de darle comportamiento. No hay plantillas en JS, ni estado global, ni DOM virtual. Tú marcas un trozo de HTML con data-controller y Stimulus le engancha una clase.

Ese enfoque encaja como un guante con una aplicación Symfony: sigues renderizando con Twig, sigues teniendo URLs y formularios normales, y añades interactividad donde hace falta.

Ya lo tienes instalado

Si creaste el proyecto con symfony new mi-app --webapp, StimulusBundle viene dentro. Si no:

composer require symfony/stimulus-bundle

La receta te deja esta estructura, que es todo lo que necesitas saber para empezar:

assets/
├── app.js                  <- tu punto de entrada
├── bootstrap.js            <- arranca Stimulus (no hay que tocarlo)
├── controllers.json        <- controladores de paquetes UX de terceros
└── controllers/
    └── hello_controller.js <- los tuyos van aquí

Todo lo que pongas en assets/controllers/ se registra solo: no hay que importar nada a mano. Y con AssetMapper tampoco hay que compilar nada.

El primer controlador

// assets/controllers/saludo_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    connect() {
        this.element.textContent = '¡Hola desde Stimulus!';
    }
}
<div data-controller="saludo">Texto original</div>

El nombre del fichero decide el identificador: saludo_controller.js se usa como data-controller="saludo". Con dos palabras, contador_texto_controller.js pasa a ser data-controller="contador-texto": guiones bajos fuera, guiones dentro.

Todo lo que verás a continuación son atributos data-* normales, así que da igual si tu HTML sale de Twig, de un CMS o de un fichero estático.

Las tres piezas que vas a usar siempre

Con esto cubres el 90 % de los casos reales:

  • Targets: elementos de dentro a los que quieres acceder desde la clase.
  • Actions: qué método se ejecuta ante qué evento.
  • Values: datos que vienen del servidor, escritos en el propio HTML.

Ejemplo 1: contador de caracteres

El clásico «te quedan 140 caracteres» de un formulario:

// assets/controllers/contador_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['campo', 'salida'];
    static values = { max: Number };

    connect() {
        this.actualizar();
    }

    actualizar() {
        const restantes = this.maxValue - this.campoTarget.value.length;

        this.salidaTarget.textContent = `${restantes} caracteres`;
        this.salidaTarget.classList.toggle('text-danger', restantes < 0);
    }
}
<div data-controller="contador" data-contador-max-value="140">
    <textarea
        data-contador-target="campo"
        data-action="input->contador#actualizar"></textarea>

    <small data-contador-target="salida"></small>
</div>

Mira cómo se escribe cada cosa, porque es la parte que más se falla: el value es data-[controlador]-[nombre]-value, el target es data-[controlador]-target y la acción va con la flecha, evento->controlador#metodo.

Y fíjate en el detalle importante: ese 140 está en el HTML, así que lo pone el servidor. Si mañana el límite viene de la entidad o de un parámetro de configuración, el JavaScript no se entera; solo cambia la plantilla.

Cada target declarado te da tres propiedades automáticas: this.campoTarget (el primero), this.campoTargets (todos) y this.hasCampoTarget (si existe). Esa última evita la mitad de los errores cuando el elemento es opcional.

Ejemplo 2: mostrar y ocultar

// assets/controllers/desplegable_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['contenido'];
    static classes = ['oculto'];

    alternar() {
        this.contenidoTarget.classList.toggle(this.ocultoClass);
    }
}
<div data-controller="desplegable" data-desplegable-oculto-class="d-none">
    <button data-action="desplegable#alternar">Ver detalles</button>

    <div data-desplegable-target="contenido" class="d-none">
        Contenido que aparece y desaparece.
    </div>
</div>

La gracia de static classes es que el nombre de la clase CSS vive en el HTML, no en el JavaScript. El mismo controlador sirve para un proyecto con Bootstrap (d-none) y para otro con tus propias clases, sin tocar el código.

En data-action, si omites el evento, Stimulus usa el que toca por defecto: click en botones y enlaces, input en campos de texto, change en selects y submit en formularios. Por eso desplegable#alternar a secas ya funciona.

Ejemplo 3: copiar al portapapeles

Este es el botón que estás viendo en cada bloque de código de este blog, escrito como controlador:

// assets/controllers/copiar_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['boton'];
    static values = {
        texto: String,
        mensaje: { type: String, default: 'Copiado' },
    };

    async copiar() {
        await navigator.clipboard.writeText(this.textoValue);

        const original = this.botonTarget.textContent;
        this.botonTarget.textContent = this.mensajeValue;

        this.temporizador = setTimeout(() => {
            this.botonTarget.textContent = original;
        }, 2000);
    }

    disconnect() {
        clearTimeout(this.temporizador);
    }
}
<div data-controller="copiar"
     data-copiar-texto-value="composer require symfony/stimulus-bundle">

    <code>composer require symfony/stimulus-bundle</code>

    <button data-copiar-target="boton" data-action="copiar#copiar">Copiar</button>
</div>

Dos cosas que enseñan mucho de Stimulus: un value puede tener valor por defecto, y disconnect() es el sitio donde se limpia lo que dejaste vivo. Ese clearTimeout parece opcional hasta que el elemento desaparece del DOM antes de los dos segundos.

Reaccionar a los cambios: los callbacks de value

Cada value declarado trae de regalo un método que se ejecuta cuando cambia:

static values = { pagina: Number };

paginaValueChanged(valor, anterior) {
    if (anterior !== undefined) {
        this.cargar(valor);
    }
}

Esto es lo más parecido a la reactividad de Vue que vas a encontrar aquí, y con una ventaja: el estado está en el atributo del HTML, así que lo ves en el inspector y puedes cambiarlo desde fuera —incluso desde el servidor, devolviendo HTML nuevo— sin sincronizar nada.

Por qué encaja tan bien con Symfony

Cuando cargas contenido por AJAX y lo insertas en la página, Stimulus conecta solo los controladores nuevos: está observando el DOM. No hay que reinicializar nada, que es exactamente el problema que tenías con jQuery.

const html = await (await fetch('/productos?pagina=2')).text();
this.listaTarget.innerHTML = html;   // los data-controller de dentro ya funcionan

Ese mismo mecanismo es el que hace que funcione con Turbo y con Live Components si algún día los añades. Y si usas un paquete de Symfony UX (gráficas, mapas, un selector con búsqueda), lo que instalas es… otro controlador Stimulus, registrado en assets/controllers.json.

Cuándo no usar Stimulus

  • Si lo resuelve el navegador solo. Un acordeón es <details> y un modal es <dialog>: no escribas JavaScript para eso.
  • Si tienes estado compartido y complejo entre muchas partes de la pantalla —un editor, un carrito con mil reglas, un panel en tiempo real—, ahí Vue está mejor preparado.
  • Si el proyecto ya monta Vue o React para todo, meter un tercer enfoque solo añade ruido.

Lo que se rompe si no lo sabes

  • El nombre del fichero manda. mi_widget_controller.js es data-controller="mi-widget". Si no pasa nada al cargar, el 90 % de las veces es esto.
  • Los atributos llevan el nombre del controlador dentro. Es data-desplegable-target, no data-target. Los helpers de Twig existen justo para no equivocarte aquí.
  • Todo lo que abras, ciérralo en disconnect(). Timers, listeners en window, observers. Si no, se acumulan cada vez que el elemento entra y sale del DOM.
  • El estado va en los values, no en propiedades sueltas. Así se refleja en el HTML, sobrevive a un reemplazo del marcado y se depura mirando el inspector.
  • Un controlador, una responsabilidad. Si tu clase tiene ocho targets y doce métodos, probablemente sean tres controladores conviviendo en el mismo elemento, que es algo perfectamente válido: data-controller="uno dos".
  • Para depurar, actívalo. En assets/bootstrap.js puedes poner app.debug = true y la consola te dirá qué controlador se conecta y qué acción se dispara.

Y ya está

Un fichero en assets/controllers/, tres atributos en el HTML y ya tienes comportamiento sin cambiar de arquitectura. Stimulus no compite con Vue: compite con esos trescientos gramos de JavaScript suelto al final de la plantilla que nadie sabe dónde empiezan ni cuándo se ejecutan.

← Volver al blog

Mi hija de 11 años me ayudó a crear el diseño de este portfolio.