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.jsesdata-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, nodata-target. Los helpers de Twig existen justo para no equivocarte aquí. - Todo lo que abras, ciérralo en
disconnect(). Timers, listeners enwindow, 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.jspuedes ponerapp.debug = truey 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.