wandres.dev
CREATEROOT Y DISPOSAL · raíces reactivas

Memoria en SPAs: disponer y no acumular

En una SPA la página no se recarga nunca, así que efectos, listeners y raíces sin dispose se apilan hasta ahogar la pestaña. Las fugas típicas, la disciplina de disponer al cambiar de vista, y cómo depurar: el aviso de computaciones huérfanas, el grafo de owners en las DevTools y los snapshots de memoria del navegador.

⏱ 17 min

Una página con recarga completa perdona todo: cada navegación borra el mundo y empieza de cero, así que una fuga vive segundos. Una SPA no perdona nada. La pestaña se abre una vez y permanece horas, y cada owner que no dispones, cada listener que no retiras, cada suscripción que no cierras, se queda. La memoria no baja: solo sube, vista tras vista, hasta que la aplicación se arrastra o el navegador la mata. Este nivel te dio las herramientas —createRoot, dispose, onCleanup—; este cierre te da la disciplina de usarlas donde una SPA las exige, y los instrumentos para cazar la fuga cuando se te escape.

🎯 Al terminar esta lección sabrás
  • Reconocer las fugas típicas de una SPA: raíces sin disponer, listeners y suscripciones huérfanas.
  • Aplicar la disciplina de disponer al cambiar de vista o al desmontar.
  • Evitar la acumulación de efectos entre navegaciones y ciclos de estado.
  • Depurar fugas con el aviso de consola, el grafo de owners y los snapshots de memoria.

Las fugas típicas

Casi todas las fugas de Solid en una SPA caben en tres moldes, y los tres comparten causa: una computación o un recurso que sobrevive al propósito que lo justificaba.

🌱

Raíz sin dispose

Abres createRoot en un handler de ruta o en un setTimeout y nunca guardas ni llamas al dispose. La raíz y todos sus efectos quedan vivos; cada visita a esa vista crea otra, y otra.

👂

Listener huérfano

Registras un addEventListener global —o un ResizeObserver, o un setInterval— sin su onCleanup. El nodo desaparece de la pantalla, pero el listener sigue enganchado al window o al document.

🔌

Suscripción externa

Te suscribes a un store de terceros, un socket o un event emitter y no cancelas al desmontar. La librería externa conserva tu callback, y con él, todo lo que capture por closure.

El contraste entre la versión que fuga y la que no cabe en unas líneas. El pecado es abrir sin cerrar; la penitencia es coser el cierre al ciclo de vida.

import { createRoot, onCleanup } from "solid-js";

// FUGA: cada visita abre una raiz que nadie dispone.
function alEntrarEnVista() {
  createRoot(() => {
    /* efectos, sondeos, suscripciones... vivos para siempre */
  });
}

// CORRECTO: la raiz muere con el owner de la vista.
function alEntrarEnVista_ok() {
  const dispose = createRoot((d) => {
    /* ...misma reactividad... */
    return d;
  });
  onCleanup(dispose); // atada al desmontaje de la vista
}

Observa que la distancia entre fuga y corrección no es el volumen de código —son idénticos salvo por dos detalles: declarar el parámetro d para obtener el dispose, y coserlo con onCleanup—. Las fugas de Solid casi nunca son aparatosas; son una línea que falta. Por eso se cuelan en revisión: el código que fuga funciona, se ve bien y pasa los tests de comportamiento. Solo delata su defecto con el tiempo y el uso, que es justo cuando ya nadie mira el diff.

Disponer al cambiar de vista

En una SPA, la unidad de vida es la vista, no la pestaña. Un router bien construido crea un owner por ruta y lo dispone al navegar fuera: por eso los efectos y limpiezas dentro de un componente de página se recogen solos al cambiar de sección. La disciplina consiste en asegurarte de que todo lo reactivo cuelgue de ese owner de ruta, de modo que herede su muerte.

Lo que escapa al owner de la ruta es lo que debes coser tú. Una suscripción externa se ata envolviéndola en un efecto con su limpieza; así el propio ciclo de la vista la abre y la cierra sin que tengas que recordar el desmontaje.

import { createEffect, onCleanup } from "solid-js";

function usarPrecio(simbolo: () => string) {
  createEffect(() => {
    const socket = abrirCanal(simbolo());     // recurso externo
    socket.onMensaje = (p) => setPrecio(p);
    onCleanup(() => socket.cerrar());          // se cierra al cambiar simbolo
  });                                          // y al desmontar la vista
}

Cuando simbolo cambia, Solid corre la limpieza del run anterior antes del nuevo: nunca hay dos canales abiertos. Cuando la vista se desmonta, corre la última limpieza pendiente. La suscripción vive exactamente lo que vive la correspondencia que la justifica, ni un milisegundo más.

La regla que se destila de aquí es una sola: no confíes en tu memoria, confía en el owner. En vez de recordar «cuando salga de esta vista, cierra el socket», haces que el socket cuelgue del owner de la vista y dejas que su muerte —ya garantizada por el router— arrastre la limpieza. Convertir cada recurso externo en un habitante del árbol de ownership traslada la responsabilidad de tu disciplina, que es falible, a la estructura del programa, que es determinista. Ese traslado es, en una frase, toda la gestión de memoria en Solid.

La excepción son los efectos que deben trascender la vista: un sondeo de notificaciones, un reloj global, la sincronización de la sesión. Esos no van en el owner de una ruta —morirían al navegar— sino en la raíz disponible del nivel anterior, o en la raíz de la app. La pregunta, de nuevo, no es «¿lo dispongo?» sino «¿de qué owner debe colgar para vivir lo justo?». Unos cuelgan de la vista y mueren al salir; otros, de la app y mueren con ella. Acertar ese emparejamiento es diseñar la topología de vida de tu SPA.

flowchart LR
V[Entrar en la vista] --> A[Abrir recurso en un efecto]
A --> N[Navegar a otra ruta]
N --> C[El owner de la vista se dispone]
C --> K[onCleanup cierra el recurso]
K --> Z[Memoria estable: nada se acumula]
style A fill:#a6e3a1,color:#11111b
style C fill:#f38ba8,color:#11111b
style Z fill:#89b4fa,color:#11111b

Depurar una fuga

Cuando la memoria sube y no sabes por qué, tienes una escalera de instrumentos, de más barato a más caro.

  • El aviso de consola. Computations created outside a createRoot or render will never be disposed es tu primera línea de defensa. Nunca lo silencies: señala exactamente una computación sin owner, es decir, una fuga garantizada. Arréglala envolviéndola en una raíz o moviéndola dentro de un owner.
  • El grafo de owners. Las DevTools de Solid muestran el árbol de owners en vivo. Navega por tu app y observa si, al salir de una vista, su subárbol desaparece. Si un subárbol persiste tras abandonar la ruta, ahí está la raíz que no se dispone.
  • Los snapshots de memoria. En las DevTools del navegador, toma un heap snapshot, ejercita el flujo sospechoso varias veces, toma otro y compara. Cuenta de nodos del DOM detached, de listeners o de instancias de tus clases que crezcan monótonamente delatan la acumulación. La grabación de allocation timeline señala en qué acción nace lo que no muere.
import { getOwner } from "solid-js";

// Sonda casera: cuenta los hijos directos del owner actual.
function contarHijos() {
  const o = getOwner() as { owned?: unknown[] } | null;
  console.log("computaciones bajo este owner:", o?.owned?.length ?? 0);
}

La estrategia que ata los tres instrumentos es la bisección. Reproduce la fuga con el flujo mínimo que la provoca, divídelo en dos mitades, mira en cuál crece la memoria y repite sobre esa mitad. El grafo de owners te dice qué subárbol no muere; el snapshot te dice cuánto retiene y qué lo mantiene con vida; el aviso de consola, cuando aparece, te clava el dedo en la línea exacta. Casi ninguna fuga sobrevive a aplicar los tres en ese orden, del más barato al más caro. Y la mejor fuga es la que nunca ocurre: si cada createRoot que abres lleva pegada su respuesta a «¿cuándo muere esto?», la mayoría no llega siquiera a nacer.

Más allá de cazarlas, conviene prevenirlas con hábitos que las vuelvan raras. Trata cada createRoot fuera de un componente como código que debe terminar en un onCleanup(dispose) o en una llamada explícita a dispose, sin que ninguno de los dos falte en la revisión. Envuelve las suscripciones a librerías externas siempre en un createEffect con su onCleanup, nunca sueltas. Una sonda de desarrollo mínima ayuda a notar desmontajes anómalos:

import { onCleanup } from "solid-js";

// Canario casero: avisa cuando una vista se limpia, y cuanto tardo.
function vigilarDesmontaje(nombre: string) {
  const t0 = performance.now();
  onCleanup(() => {
    console.debug(`${nombre} desmontada tras ${performance.now() - t0}ms`);
  });
}

Si esperabas ese aviso al salir de una vista y no llega, la vista no se está desmontando: ahí tienes la fuga, señalada antes de abrir un solo snapshot.

💡
El presupuesto de efectos por vista

Una heurística útil en revisión: cuenta cuántos efectos y suscripciones abre una vista y comprueba que cada uno tiene su cierre. No es que muchos efectos sean malos —Solid los ejecuta con precisión quirúrgica—, sino que cada recurso abierto es una promesa de cierre, y las promesas sin cumplir se acumulan calladas. Si una vista abre tres sockets y cierra dos, no tienes un bug visible: tienes una fuga que solo se manifestará tras suficientes visitas. Auditar el balance abrir/cerrar por vista atrapa la mayoría antes de que lleguen a producción.

⚠️
El culpable casi nunca es un signal; casi siempre un efecto o un recurso

Un signal huérfano no es una fuga grave: es un valor con una lista de observadores que, si nadie lo lee, no retiene gran cosa. Lo que fuga de verdad son las computaciones —efectos y memos sin dispose— y los recursos externos —listeners, intervalos, sockets, observers— que esas computaciones abren y no cierran. Cuando busques una fuga, no persigas señales: persigue efectos sin owner y recursos sin onCleanup. El árbol de owners te dice cuáles de esas computaciones deberían haber muerto y no lo hicieron.

La memoria es el árbol de ownership vuelto visible

La gestión de memoria en Solid no es una disciplina aparte que se añade encima de la reactividad: es la reactividad misma mirada por su lado temporal. El grafo de dependencias dice quién reacciona a quién; el árbol de ownership dice cuánto vive cada cosa y quién la mata. Una fuga es, siempre, un desajuste entre esos dos: algo que reacciona pero cuya vida nadie acotó, un nodo del árbol que perdió el hilo que lo ataba a la muerte de su padre. Por eso el remedio nunca es un recolector más listo ni un truco de rendimiento, sino una pregunta de diseño que ya sabes formular: ¿de qué owner cuelga esto, y cuándo debe morir? Si la respuesta es «de ninguno» o «nunca», tienes una fuga en potencia, y la conviertes en fuga real en cuanto ese código corre más de una vez —cosa que en una SPA, donde la página no se recarga, ocurre siempre—. La madurez que cierra este nivel consiste en llevar esa pregunta pegada a cada createRoot, cada createEffect y cada recurso externo que toques: abrir es fácil y visible; cerrar es lo que se olvida y lo que hunde las aplicaciones que se olvidan. Solid te dio un modelo donde cerrar es tan estructural como abrir —el mismo árbol que crea es el que destruye, en orden, de abajo hacia arriba— y te dio el instrumento para verlo. Usar ese modelo con disciplina es la diferencia entre una SPA que corre igual de rápida tras dos horas de uso y una que se va apagando sola, vista a vista, hasta que alguien recarga y el problema, invisible, vuelve a empezar.

⚔️ Caza y sella una fuga
  1. Escribe una vista que abra createRoot sin disponer y navega a ella varias veces; observa en las DevTools de Solid cómo se acumulan subárboles de owners.
  2. Corrige la vista atando la raíz con onCleanup(dispose) y confirma que ahora cada subárbol desaparece al salir.
  3. Registra un setInterval sin onCleanup, provoca la fuga navegando de ida y vuelta, y arréglala con la limpieza; verifica en consola que los ticks paran.
  4. Toma dos heap snapshots alrededor de un flujo repetido y encuentra qué cuenta crece de forma monótona.
  5. Desactiva un onCleanup a propósito y localiza la fuga usando solo el aviso de consola y el grafo de owners, sin mirar el código sospechoso primero.