Fugas de memoria y efectos huérfanos
Un efecto que sobrevive a su componente es una fuga: intervalos que siguen disparando, listeners que retienen closures, suscripciones que nunca se cierran. El árbol de ownership las previene atando cada recurso a la vida de su dueño.
Anatomía de una fuga
Una fuga reactiva es siempre lo mismo: un recurso cuyo ciclo de vida es más largo que el del componente que lo creó. El caso de manual es un setInterval sin limpieza.
import { createSignal } from "solid-js";function Reloj() { const [t, setT] = createSignal(Date.now()); setInterval(() => setT(Date.now()), 1000); // FUGA: nadie lo detiene return <time>{t()}</time>;}Cuando Reloj se desmonta, el intervalo sigue vivo. Cada segundo llama a setT, un setter cuyo signal ya no tiene consumidores visibles; el callback mantiene con vida, por su closure, al signal y a todo lo que capture. Multiplica esto por un componente que se monta y desmonta cien veces —una fila de una lista, una pestaña que se abre y cierra— y tendrás cien intervalos corriendo en paralelo, cada uno reteniendo su porción de memoria. La corrección cabe en dos líneas:
import { createSignal, onCleanup } from "solid-js";function Reloj() { const [t, setT] = createSignal(Date.now()); const id = setInterval(() => setT(Date.now()), 1000); onCleanup(() => clearInterval(id)); // atado al owner del componente return <time>{t()}</time>;}Las cuatro formas de quedar huérfano
Casi todas las fugas en Solid caen en una de estas cuatro categorías:
La segunda merece atención especial. Si creas una computación donde no hay owner activo —en el nivel de módulo, o dentro de un callback suelto—, Solid lo detecta y en desarrollo emite el aviso “computations created outside a createRoot or render will never be disposed”. Ese mensaje no es ruido: es el sistema diciéndote, con todas las letras, que acabas de fabricar un huérfano. Trátalo como un error, no como una advertencia.
import { createEffect } from "solid-js";// nivel de modulo: NO hay owner. Este efecto no se dispone jamas.createEffect(() => console.log(estadoGlobal())); // aviso en dev, fuga en prodCómo el ownership las previene
El antídoto es siempre el mismo principio, aplicado con disciplina: por cada adquisición, una liberación atada al owner. Si abres, registra el cierre en un onCleanup en la línea siguiente. Como el owner se dispone solo al desmontar, la liberación corre sola. No hay que recordar un desmontaje: se escribió junto a la apertura.
Para cada tipo de recurso hay un cierre concreto: clearInterval y clearTimeout para temporizadores; removeEventListener para listeners; disconnect para ResizeObserver, IntersectionObserver y MutationObserver; close para un WebSocket; el abort de un AbortController para una petición; cancelAnimationFrame para una animación; y el unsubscribe que devuelva cualquier store o emisor externo.
import { onCleanup } from "solid-js";function seguirMedia(consulta, alCambiar) { const mq = window.matchMedia(consulta); mq.addEventListener("change", alCambiar); onCleanup(() => mq.removeEventListener("change", alCambiar));}Cuando necesitas reactividad fuera de un componente —una utilidad global, un módulo que orquesta estado sin interfaz—, no hay owner que te ampare, y ahí es donde entra createRoot. Abre una raíz explícita, corre tu código dentro y quédate con la función dispose; eres tú quien decide cuándo cerrar el ámbito, pero el mecanismo de limpieza es el mismo árbol de siempre.
import { createRoot, createEffect, onCleanup } from "solid-js";const dispose = createRoot((dispose) => { createEffect(() => sincronizar(fuente())); onCleanup(() => cerrarFuente()); return dispose;});// al terminar, cierras el ambito a mano y todo se limpia:dispose();Más allá de la memoria: trabajo zombi
No toda fuga es memoria retenida; muchas son trabajo inútil que sigue ejecutándose. Un intervalo huérfano no solo ocupa memoria: cada segundo despierta el hilo, recalcula y llama a un setter cuyo resultado nadie observa. Peor aún, si ese setter escribe en un signal que otros efectos leen, puede disparar cadenas reactivas completas sobre estado que la interfaz ya no muestra —el clásico problema del hijo zombi—, y a veces provoca errores al tocar nodos del DOM que ya no existen.
import { onCleanup } from "solid-js";function Presencia(props) { const bus = window.eventos; const alEntrar = (u) => registrar(u); bus.on("entra", alEntrar); onCleanup(() => bus.off("entra", alEntrar)); // sin esto: callback zombi return <ul>{props.lista}</ul>;}Sin el onCleanup, el emisor global bus conserva una referencia a alEntrar, y ese closure retiene a props y a todo el componente: ni el recolector de basura puede liberarlo, ni el handler deja de correr. La suscripción es una de las fugas más traicioneras porque no hay temporizador visible que delate el problema; solo un callback que nadie recuerda haber dejado enganchado.
La regla que lo cierra todo es la simetría temporal: el handler debe dejar de existir para el sistema en el mismo instante en que el componente deja de existir para el usuario. El árbol de ownership hace que ese “mismo instante” sea automático —la disposición del owner—, y onCleanup es donde declaras qué significa “dejar de existir” para cada recurso concreto.