wandres.dev
OWNERSHIP Y CLEANUP · onCleanup, getOwner

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.

⏱ 14 min

Una fuga de memoria en una interfaz reactiva rara vez es dramática: no revienta, se arrastra. Un intervalo que sigue disparando tras desmontar su componente, un listener que retiene en su closure todo el árbol que lo creó, una suscripción que nadie cerró. El código parece funcionar, pero la memoria crece, aparecen actualizaciones fantasma sobre nodos que ya no existen, y el rendimiento se degrada sin causa aparente. Esta lección cierra el nivel mostrando cómo nacen los efectos huérfanos y por qué el árbol de ownership, bien usado, los vuelve estructuralmente imposibles.

🎯 Al terminar esta lección sabrás
  • Reconocer la anatomía de una fuga: un recurso que sobrevive a su owner.
  • Enumerar las cuatro formas típicas de crear un efecto huérfano.
  • Aplicar el patrón de adquirir y liberar atado a onCleanup para cerrarlas.
  • Usar createRoot para ámbitos desligados que debes disponer a mano.

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:

⏱️

Recurso sin limpieza

Abres un intervalo, un listener o un observador y no registras su onCleanup.

🌫️

Computación sin owner

Creas un efecto fuera de render o createRoot; nunca tendrá quién lo disponga.

Reactividad tras un await

Creas efectos o limpiezas después de un salto asíncrono sin runWithOwner.

📡

Suscripción global

Te enganchas a un emisor o store externo y no te desenganchas al desmontar.

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 prod

Có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.

flowchart LR
A[adquirir recurso] --> B[registrar onCleanup]
B --> C[owner se dispone al desmontar]
C --> D[liberar recurso]
A2[adquirir sin cleanup] --> F[recurso huerfano sigue vivo]
style D fill:#a6e3a1,color:#11111b
style F fill:#f38ba8,color:#11111b

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();
⚠️
Un createRoot sin dispose es una fuga con permiso

createRoot resuelve el problema del owner ausente, pero traslada la responsabilidad a ti: si abres una raíz y nunca llamas a su dispose, has creado una fuga con todas las de la ley, solo que sin el aviso de consola que delataría un huérfano accidental. Usa createRoot únicamente para ámbitos cuyo final controlas con claridad, y empareja siempre su apertura con la llamada a dispose que la cierra.

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.

El ownership convierte la disciplina en estructura

En la mayoría de los entornos, no tener fugas es una cuestión de disciplina personal: acordarte, en cada sitio, de cerrar lo que abriste, con la vista puesta en un desmontaje que ocurrirá lejos, en otro archivo, meses después. Esa disciplina falla porque depende de la memoria humana y del emparejamiento manual entre dos puntos distantes del código. La aportación de Solid es mover esa garantía del terreno de la disciplina al de la estructura. El árbol de ownership hace que cada recurso pertenezca a un ámbito, que cada ámbito conozca sus limpiezas y que la destrucción sea un recorrido determinista que el sistema ejecuta por ti. Tu única obligación se reduce a una regla local, verificable de un vistazo: cada adquisición lleva su onCleanup al lado. Cumplida esa regla en el punto donde abres el recurso —sin mirar dónde ni cuándo morirá el componente—, la ausencia de fugas deja de ser algo que esperas y se vuelve algo que el árbol garantiza. Es la misma filosofía que recorre todo Solid: no te pide ser más cuidadoso, te da un modelo donde el cuidado ya está incorporado. Las fugas no se evitan recordándolas; se evitan usando bien el árbol que las hace imposibles.

⚔️ Caza y sella una fuga
  1. Escribe el Reloj con la fuga, móntalo y desmóntalo varias veces con un Show, y observa en las devtools cómo los intervalos se acumulan.
  2. Añade el onCleanup con clearInterval y confirma que ahora el número de intervalos vuelve a cero al desmontar.
  3. Crea un efecto en el nivel de módulo y provoca a propósito el aviso “created outside a createRoot or render”; razona por qué es una fuga.
  4. Envuelve ese efecto en un createRoot con su dispose y comprueba que ya no avisa.
  5. Suscríbete a un emisor global desde un componente y desengánchate en onCleanup; verifica que tras desmontar no llegan más eventos.