Directivas use: clickOutside y tooltip
Qué compila use:directiva, cómo escribir directivas personalizadas como clickOutside y tooltip con accesor reactivo y limpieza con onCleanup, por qué el nombre no debe tree-shakearse, y cómo tiparlas augmentando JSX.Directives para que TypeScript las reconozca.
Un ref con callback te da el nodo una vez; una directiva use: te da algo mejor: comportamiento reutilizable, con nombre, reactivo y con limpieza automática, que enchufas a cualquier elemento con una sola palabra en el JSX. use:clickOutside, use:tooltip, use:draggable son piezas de lógica de DOM empaquetadas. Bajo el capó no hay magia: una directiva es solo una función que recibe el elemento y un accesor, y el compilador la llama al crear el nodo. Dominarlas incluye el detalle que a todos se les escapa: cómo tiparlas y por qué el bundler puede borrarlas.
- Entender qué compila
use:directiva={valor}y la firma(el, accessor)de una directiva. - Escribir directivas con estado, efecto reactivo y limpieza mediante
onCleanup. - Construir
clickOutsideytooltipcompletas y reactivas. - Tipar las directivas augmentando
JSX.Directivesy evitar que el nombre se tree-shakee.
Qué es una directiva
El compilador transforma use:foo={bar} en una llamada: foo(elemento, () => bar). Es decir, una directiva es una función que recibe el nodo del DOM y un accesor —una función que al invocarla devuelve el valor actual del binding—. Se ejecuta cuando el elemento se crea, dentro del ámbito reactivo del componente, lo que significa que puede registrar efectos y limpieza que seguirán el ciclo de vida del nodo.
import { onCleanup } from "solid-js";
import type { Accessor } from "solid-js";
// Firma canonica: (elemento, accesor del valor).
function autofocus(el: HTMLInputElement, value: Accessor<boolean>) {
if (value()) el.focus();
}
// Uso: el compilador emite autofocus(input, () => true).
<input use:autofocus={true} />;
flowchart LR U[Directiva use en el JSX] --> T[El compilador invoca la funcion con el nodo y el accesor] T --> D[La directiva corre al crear el elemento] D --> E[Registra eventos y limpieza con onCleanup] D --> R[Un createEffect reacciona al accesor] style D fill:#a6e3a1,color:#11111b style R fill:#89b4fa,color:#11111b
Como el accesor es reactivo, si lo lees dentro de un createEffect la directiva reacciona a los cambios del valor. Y como corre dentro del owner del componente, un onCleanup dentro de ella se dispara cuando el elemento se destruye: por eso las directivas son el hogar natural de listeners que hay que quitar.
clickOutside: la directiva de referencia
El caso de uso clásico: cerrar un menú o modal cuando el usuario hace click fuera. La directiva engancha un listener al documento y lo retira al limpiar.
import { onCleanup } from "solid-js";
import type { Accessor } from "solid-js";
export function clickOutside(el: HTMLElement, accessor: Accessor<() => void>) {
const onClick = (e: MouseEvent) => {
// Si el click NO cayo dentro del elemento, invoca el callback del usuario.
if (!el.contains(e.target as Node)) accessor()?.();
};
document.body.addEventListener("click", onClick);
onCleanup(() => document.body.removeEventListener("click", onClick));
}
// Uso: cerrar el panel al hacer click fuera.
function Menu() {
const [abierto, setAbierto] = createSignal(true);
return (
<Show when={abierto()}>
<div use:clickOutside={() => setAbierto(false)} class="panel">
contenido del menu
</div>
</Show>
);
}
El onCleanup es lo que hace correcta a esta directiva: cuando <Show> desmonta el panel, el listener del documento se retira solo. Sin él, tendrías un listener huérfano por cada apertura, un fuga clásica.
tooltip: estado, reactividad y limpieza
Una directiva puede mantener estado interno y reaccionar al accesor. Un tooltip que muestra un texto al pasar el ratón, y que se actualiza si el texto cambia:
import { createEffect, onCleanup } from "solid-js";
import type { Accessor } from "solid-js";
export function tooltip(el: HTMLElement, accessor: Accessor<string>) {
let burbuja: HTMLDivElement | undefined;
const mostrar = () => {
burbuja = document.createElement("div");
burbuja.className = "tooltip";
burbuja.textContent = accessor();
const r = el.getBoundingClientRect();
burbuja.style.left = `${r.left}px`;
burbuja.style.top = `${r.bottom + 6}px`;
document.body.append(burbuja);
};
const ocultar = () => {
burbuja?.remove();
burbuja = undefined;
};
el.addEventListener("mouseenter", mostrar);
el.addEventListener("mouseleave", ocultar);
// Reactivo: si el texto cambia mientras el tooltip esta visible, se actualiza.
createEffect(() => {
const texto = accessor();
if (burbuja) burbuja.textContent = texto;
});
onCleanup(() => {
el.removeEventListener("mouseenter", mostrar);
el.removeEventListener("mouseleave", ocultar);
ocultar();
});
}
// Uso:
<button use:tooltip={etiqueta()}>Ayuda</button>;
use: funciona porque el compilador tiene un elemento del DOM real al que aplicar la función. Un componente no compila a un elemento, así que <MiComponente use:foo> no hace nada. Si necesitas comportamiento reutilizable sobre un componente, pásalo como prop o expón un ref. Las directivas son un mecanismo de la capa del DOM, no de la de composición de componentes.
Tipar directivas y el problema del tree-shaking
Dos escollos que rara vez se explican bien. Primero, TypeScript no sabe que use:tooltip={string} es válido a menos que augmentes JSX.Directives, un mapa de nombre de directiva a tipo del valor del accesor:
declare module "solid-js" {
namespace JSX {
interface Directives {
clickOutside: () => void; // el tipo que espera use:clickOutside={...}
tooltip: string; // use:tooltip espera un string
}
}
}
Esta augmentación debe vivir en un módulo (un archivo con import/export); si el archivo no tiene ninguno, añade export {} para forzarlo. Con ella, use:tooltip={42} da error de tipos y obtienes autocompletado.
Segundo, el tree-shaking. El nombre de la directiva solo aparece en la posición use:foo, que el minificador no reconoce como un uso real de la variable, así que puede borrar el import por “no usado”. Solid lo tiene en cuenta y el plugin de compilación marca el nombre, pero con imports desde otro módulo asegúrate de que no se elimine.
Como el compilador emite literalmente foo(el, () => valor), foo tiene que ser un identificador en ámbito: un import o una función local, referida por su nombre exacto. No puede ser una expresión ni una propiedad (use:obj.foo no vale). Si un linter marca el import como no usado, es un falso positivo del punto de vista del compilador de Solid: el plugin oficial de ESLint para Solid ya lo entiende y no te molesta.
La forma de ver use: con claridad es reconocer que es la evolución natural del callback ref del nivel anterior. Un callback ref te daba el elemento una vez, anónimo y sin ceremonia. Una directiva toma ese mismo gancho de creación y le añade cuatro cosas que lo convierten en una unidad de comportamiento reutilizable: un nombre, con el que la enchufas declarativamente a cualquier nodo; un accesor reactivo, con el que su comportamiento sigue cambiando con el estado mucho después de la creación; limpieza con onCleanup atada al ciclo de vida del elemento, que elimina las fugas de listeners por diseño; y un tipo en JSX.Directives, que la integra en el sistema de tipos del JSX como si fuera un atributo más. Por eso las directivas son el idioma de Solid para envolver APIs imperativas del DOM —observadores, gestos, integraciones con librerías— en piezas declarativas y componibles. No introducen un modelo nuevo: reusan el owner, los efectos y la limpieza que ya gobiernan toda la reactividad. Cuando ves use:clickOutside={cerrar} y entiendes que detrás hay clickOutside(nodo, () => cerrar) corriendo dentro del mismo grafo que todo lo demás, has cerrado el círculo de este nivel: eventos, binding, refs y directivas son cuatro vistas de una única idea, la de conectar tu estado reactivo con el árbol imperativo del navegador.
- Implementa
clickOutsidey úsala para cerrar un<Show>; verifica con las DevTools que el listener del documento desaparece al desmontar. - Escribe
tooltipreactivo y cambia su texto mientras está visible con una señal; comprueba que la burbuja se actualiza. - Augmenta
JSX.Directivespara ambas y provoca un error de tipos pasando un valor incorrecto ause:tooltip. - Intenta poner
use:tooltipsobre un componente propio y observa que no hace nada; razona por qué. - Crea una tercera directiva
use:longPress={callback}que dispare tras mantener pulsado 500 ms, cononCleanupque cancele el temporizador.