wandres.dev
EVENTOS Y BINDING · on:, delegación, refs

Eventos: delegados frente a nativos

Los dos sistemas de eventos de Solid: los delegados con onClick y su listener global en el document, los nativos con on:click, cuándo la delegación gana en rendimiento y cómo pasar datos al handler sin crear closures por fila.

⏱ 14 min

Escuchar un click parece la operación más trivial del frontend, pero en Solid esconde una decisión de arquitectura. onClick y on:click no son sinónimos: el primero registra un handler en un sistema de delegación que vive en el document, el segundo llama a addEventListener sobre el nodo concreto. Entender la diferencia es entender cuántos listeners crea tu app, en qué orden se disparan y por qué a veces un evento de un widget de terceros no llega nunca a tu código.

🎯 Al terminar esta lección sabrás
  • Distinguir el binding delegado onClick del nativo on:click y qué compila cada uno.
  • Comprender la delegación de eventos y su modelo de coste en listas grandes.
  • Pasar datos al handler con la forma de tupla [fn, dato] sin closures por elemento.
  • Reconocer los casos límite (portales, stopPropagation, componentes de terceros) que obligan a lo nativo.

Dos formas de escuchar

El compilador JSX de Solid trata dos sintaxis de forma radicalmente distinta:

// DELEGADO: nombre en camelCase. Se registra en el sistema de delegación.
<button onClick={handleClick}>Guardar</button>

// NATIVO: prefijo on: con el nombre exacto. Es addEventListener directo.
<button on:click={handleClick}>Guardar</button>

Con onClick, Solid no llama a addEventListener sobre el botón. En su lugar, guarda tu handler en una propiedad interna del nodo (button.$$click = handleClick) y se asegura de que exista un único listener de click colgado del document. Con on:click, el nombre se respeta tal cual (es sensible a mayúsculas) y se hace button.addEventListener("click", handleClick) sobre ese nodo y solo ese nodo.

Conceptualmente, el compilador emite dos cosas bien distintas:

// onClick delegado: guarda el handler y registra el tipo en el document.
button.$$click = handleClick;
delegateEvents(["click"]);   // idempotente: un unico listener global por tipo

// on:click nativo: addEventListener directo sobre este nodo y solo este.
button.addEventListener("click", handleClick);

La lista de eventos que Solid delega es curada y finita: click, input, keydown, keyup, pointerdown, pointerup, mousedown, mouseup, touchstart y unos cuantos más, todos ellos eventos que burbujean. Si escribes onMouseEnter (un evento que no burbujea y no está en la lista), Solid no puede delegarlo: cae al camino nativo y hace un addEventListener normal por ti. Es decir, onXxx es “delega si puedes, si no adjunta nativo”, mientras que on:xxx es “siempre nativo, sin excepciones”.

Delegación: un listener para gobernarlos a todos

La delegación explota que los eventos burbujean del target hacia arriba. En lugar de mil listeners de click (uno por fila de una tabla), hay uno solo en el document. Cuando disparas un click, el evento burbujea hasta el document, allí el único listener de Solid lo captura y simula la propagación: recorre desde event.target hacia arriba, y en cada nodo que tenga un $$click guardado, lo invoca fijando currentTarget a ese nodo. Respeta stopPropagation mirando una bandera interna mientras asciende.

flowchart TD
A[Handler en el JSX] --> B{El nombre esta en la lista de delegados}
B -->|onClick y burbujea| C[Un unico listener por tipo en el document]
B -->|on nativo o evento no delegable| D[addEventListener en el propio nodo]
C --> E[Al disparar Solid sube de target a nodo y fija currentTarget]
D --> F[Se dispara solo en ese elemento]
style C fill:#a6e3a1,color:#11111b
style D fill:#89b4fa,color:#11111b

Las ventajas son de escala. La memoria no crece con el número de elementos: añadir diez mil filas no añade diez mil listeners. Y como el listener vive en el document, funciona con nodos que aparecen después: no hay que re-enganchar nada al insertar. Por eso el patrón de listas en Solid casi nunca necesita pensar en el coste de los handlers.

Ese mismo diseño esconde una trampa de orden que separa a quien sabe de quien copia. Como el listener delegado vive en el document, se dispara después de que el evento haya burbujeado por todo el árbol. Cualquier addEventListener nativo colgado de un ancestro intermedio se ejecuta antes que tu handler delegado, porque está más cerca del target en el camino de burbujeo. Y si ese listener intermedio llama a event.stopPropagation(), el evento nunca alcanza el document: tu onClick delegado no se dispara jamás.

📝
Portales y librerías externas

Solid parchea <Portal> para reconstruir el camino de propagación, de modo que un onClick sobre contenido teletransportado fuera del árbol sigue funcionando por delegación. Pero con un widget de terceros que hace stopPropagation internamente, o con contenido dentro de un shadow DOM opaco, la delegación puede romperse. La regla práctica: si un evento delegado “no llega”, cámbialo a on: para adjuntar el listener directamente al nodo y saltarte el viaje hasta el document.

Datos en el handler sin closures

El instinto de React es capturar datos en una closure: onClick={() => remove(item.id)}. Funciona, pero crea una función nueva por cada elemento de la lista. Solid ofrece una forma de tupla que evita esa asignación:

function Fila(props: { id: number; nombre: string }) {
  // remove recibe el dato como PRIMER argumento y el evento como segundo.
  return <li onClick={[remove, props.id]}>{props.nombre}</li>;
}

function remove(id: number, e: MouseEvent) {
  // e.currentTarget es el <li>; id llega ya vinculado, sin closure.
  console.log("borrar", id, e.currentTarget);
}

Cuando el valor de un binding de evento es un array [fn, dato], Solid llama a fn(dato, evento). La misma referencia de función remove se reutiliza en las mil filas; lo único que cambia es el dato vinculado, que Solid guarda aparte. Más allá del micro-ahorro de asignaciones, el patrón separa la lógica (una función pura y testeable) de la plantilla. Un ejemplo completo lo reúne todo —delegación, tupla y classList reactivo— en un componente idiomático:

import { createSignal, For } from "solid-js";

function ListaSeleccionable(props: { items: { id: number; texto: string }[] }) {
  const [sel, setSel] = createSignal<number | null>(null);

  // Handler tipado y reutilizado por todas las filas: el dato primero.
  const elegir = (id: number, e: MouseEvent) => {
    setSel(id);
    (e.currentTarget as HTMLLIElement).scrollIntoView({ block: "nearest" });
  };

  return (
    <ul>
      <For each={props.items}>
        {(it) => (
          <li onClick={[elegir, it.id]} classList={{ sel: sel() === it.id }}>
            {it.texto}
          </li>
        )}
      </For>
    </ul>
  );
}

El binding nativo, por su parte, abre la puerta a lo que la delegación no puede dar:

// Fase de captura: corre antes que los handlers de los hijos.
<div oncapture:click={enCaptura}>zona</div>

// Evento personalizado con mayusculas que emite un web component.
<mi-widget on:MiEvento={alCustom} />

// Opciones del listener nativo: passive, once, capture.
<div on:wheel={{ passive: true, handleEvent: alScroll }} />
💡
on: para capture, eventos personalizados y opciones

El binding nativo es tu escape cuando la delegación no encaja. Necesitas on: para eventos con mayúsculas o guiones que emiten los web components (on:MiEvento), para forzar el camino nativo, y para la fase de captura con el espacio oncapture:click. Para opciones del listener, on: acepta un objeto con handleEvent: on:wheel={{ passive: true, handleEvent: alDesplazar }} registra un listener pasivo real, algo que la delegación no puede ofrecerte porque su único listener global es compartido.

Cuándo cada uno

🌐

Delegado (onClick)

Tu opción por defecto. Ideal para listas y árboles grandes: un listener por tipo, memoria constante, funciona con nodos dinámicos. Perfecto con la forma de tupla para datos por fila.

🎯

Nativo (on:click)

Cuando necesitas la fase de captura, opciones como passive u once, eventos personalizados con mayúsculas, o esquivar un stopPropagation de terceros. Un listener por nodo, control total.

La delegación no es un truco, es una topología

Interioriza que en Solid los eventos no viven en los elementos, sino en el document, y que tus handlers son datos que cuelgan de los nodos hasta que la burbuja los recoge. Esta inversión explica todo lo demás: por qué diez mil filas cuestan un listener y no diez mil, por qué el currentTarget que ves es simulado y no el nativo del navegador, por qué el orden relativo a un addEventListener externo te puede sorprender, y por qué un stopPropagation río abajo puede silenciar tu código. No es un detalle de implementación que puedas ignorar: es la topología de tu aplicación. El framework te da lo delegado por defecto porque es lo correcto el 95% de las veces —memoria plana, cero re-enganches— y te reserva on: como bisturí para el 5% en que necesitas el listener pegado al metal. Dominar eventos en Solid es saber, ante cada interacción, en qué punto del camino de propagación estás escuchando y por qué.

⚔️ Mide la delegación
  1. Renderiza una lista de 5.000 filas con <For>, cada una con onClick={[seleccionar, i]}. Comprueba en las DevTools que hay un solo listener de click en el document.
  2. Cambia una fila a on:click y verifica que ahora aparece un listener adicional pegado a ese <li>.
  3. Envuelve un botón delegado en un <div> con un addEventListener("click") nativo que llame a stopPropagation. Observa que el onClick interno deja de dispararse y razona por qué.
  4. Reescribe un handler que usaba una closure () => borrar(id) a la forma de tupla [borrar, id] y explica qué asignación te ahorras por fila.
  5. Añade on:wheel={{ passive: true, handleEvent }} a un contenedor con scroll y confirma en consola que el navegador no te avisa de un listener no pasivo.