wandres.dev
EL ÁRBOL DE DUEÑOS · Ownership y disposal automático

Raíces desacopladas y propiedad manual

Cómo crear un ámbito que no pertenece a nadie, cuándo hace falta, y las primitivas para capturar un dueño y volver a él más tarde — la salida de emergencia para el código asíncrono.

⏱ 16 min

El árbol de propiedad crece hacia abajo automáticamente, pero a veces hace falta romperlo: crear un ámbito que nadie posea, cuya vida controles tú. Y a veces hace falta lo contrario: recuperar un dueño que se perdió al cruzar una frontera asíncrona. Las dos operaciones son la salida de emergencia del sistema, y como toda salida de emergencia conviene saber exactamente cuándo se usa y qué se pierde al usarla.

🎯 Al terminar esta lección sabrás
  • Crear una raíz desacoplada y controlar su desecho manualmente.
  • Identificar los tres casos donde hace falta una raíz.
  • Usar la captura y restauración de dueño para código asíncrono.
  • Reconocer el riesgo de una raíz que nadie desecha.

La raíz

Una raíz es un ámbito sin padre. Se crea poniendo el dueño actual a un nodo nuevo cuyo campo de padre es nulo, y devolviendo una función que lo desecha.

function raiz(fn) {
  const nodo = { hijos: [], limpiezas: [], fuentes: null };
  const duennoAnterior = Duenno, obsAnterior = Observador;
  Duenno = nodo;
  Observador = null;              // una raiz no rastrea nada de fuera
  try {
    return fn(() => desechar(nodo));
  } finally {
    Duenno = duennoAnterior;
    Observador = obsAnterior;
  }
}

Dos detalles importan. El primero, que se devuelve la función de desecho al cuerpo: quien crea la raíz recibe el control de su vida. El segundo, que Observador se pone a null: dentro de una raíz, las lecturas no se atribuyen a la computación exterior. Sin eso, crear una raíz dentro de un efecto haría que el efecto se suscribiera a todo lo que la raíz lea, que es justo lo que no se quiere.

let tirar;
raiz((desechar) => {
  tirar = desechar;
  efecto(() => pintar(datos()));    // vive hasta que llamemos a tirar
});

// mucho despues
tirar();                             // todo lo creado dentro se destruye

Los tres casos donde hace falta

El punto de entrada de la aplicación. Tiene que haber una raíz en la cima: alguien que posea todo lo demás. En un framework, esa raíz la crea el propio arranque y no la ves.

Un ámbito de vida distinta a la de su creador. Un modal creado desde un componente pero que debe sobrevivir a él, una animación que continúa tras desmontar, un caché global inicializado desde un componente. Si se crearan en el ámbito del creador, morirían con él.

Aislar una parte que se destruye y se recrea a menudo. Una lista donde cada fila tiene su propio ámbito para poder desechar una fila sin tocar las demás. Es lo que hacen internamente las primitivas de listas de los frameworks de esta familia.

⚠️
Una raiz sin desechar es una fuga garantizada

Como nadie la posee, nadie la va a limpiar. Si pierdes la referencia a su función de desecho, todo lo que hay dentro vive para siempre: sus efectos siguen suscritos, sus temporizadores siguen disparando, sus nodos del DOM siguen retenidos. La regla es simple y no admite excepciones: cada raíz que crees tiene que tener un dueño identificable en tu diseño, aunque no sea el motor quien lo gestione. Si no puedes nombrar quién la va a desechar y cuándo, no la crees.

Capturar y restaurar el dueño

El segundo problema es el inverso. Al cruzar una frontera asíncrona, el dueño actual se pierde igual que el observador. Todo lo que se cree después no pertenece a nadie.

efecto(() => {
  setTimeout(() => {
    alLimpiar(() => console.log('esto no se registra en ningun sitio util'));
  }, 100);
});

La solución es capturar el dueño mientras está disponible y restaurarlo dentro del callback.

function duennoActual() { return Duenno; }

function conDuenno(duenno, fn) {
  const anterior = Duenno, obsAnterior = Observador;
  Duenno = duenno;
  Observador = null;                 // se restaura la propiedad, no el rastreo
  try { return fn(); } finally { Duenno = anterior; Observador = obsAnterior; }
}

// uso
efecto(() => {
  const d = duennoActual();
  setTimeout(() => {
    conDuenno(d, () => {
      const id = setInterval(tic, 1000);
      alLimpiar(() => clearInterval(id));   // ahora si se registra donde toca
    });
  }, 100);
});

Fíjate en que se restaura la propiedad pero no el rastreo. Es deliberado: restaurar el observador haría que las lecturas dentro del callback tejieran aristas hacia una computación que ya terminó su ejecución, y esas aristas serían basura. La propiedad sí tiene sentido restaurarla, porque la pregunta quién limpia esto sigue teniendo la misma respuesta aunque haya pasado tiempo.

flowchart TB
E[efecto en ejecucion] --> C[capturar el duenno actual]
C --> A[cruzar una frontera asincrona]
A --> P[el duenno global ya es otro]
P --> R[restaurar el duenno capturado]
R --> N[lo que se cree aqui pertenece al efecto original]
style E fill:#cba6f7,color:#11111b
style C fill:#89b4fa,color:#11111b
style A fill:#f9e2af,color:#11111b
style P fill:#f38ba8,color:#11111b
style R fill:#89b4fa,color:#11111b
style N fill:#a6e3a1,color:#11111b

Las primitivas en cada motor

Solid tiene createRoot, getOwner y runWithOwner, que son exactamente las tres funciones de esta lección. Vue tiene effectScope, que admite un argumento para crearlo desacoplado del ámbito padre, más getCurrentScope y onScopeDispose; el patrón de captura y restauración se consigue guardando el ámbito y llamando a su método de ejecución. Angular resuelve el mismo problema con el inyector: se captura un Injector o un DestroyRef y se usa al crear el efecto más tarde. Svelte tiene $effect.root, que devuelve una función de desecho igual que la raíz de esta lección.

Todos coinciden en el diseño porque el problema es el mismo, y merece señalarse que ninguno lo hace automático: la ruptura del árbol tiene que ser explícita, precisamente porque romper la propiedad es una decisión con consecuencias que el motor no puede tomar por ti.

La raiz es donde acaba la garantia y empieza tu responsabilidad

Vale la pena ver con claridad qué se está haciendo al crear una raíz: estás saliendo del sistema de garantías. Dentro del árbol, el motor te promete que todo lo que crees se limpiará cuando su ámbito muera, y esa promesa es lo que hace que puedas escribir efectos sin pensar. Al crear una raíz, esa promesa se detiene en ese punto y a partir de ahí el garante eres tú. Es el mismo tipo de frontera que un bloque unsafe, un puntero crudo o una desactivación de una comprobación del compilador: el sistema reconoce que hay casos legítimos que no puede verificar y te da la llave, con la condición implícita de que sepas lo que haces. De ahí salen dos disciplinas que merecen ser costumbre. Primera: cada raíz debe tener su desecho escrito en el mismo commit y a ser posible en el mismo fichero, porque una raíz cuyo desecho está a diez ficheros de distancia se convertirá en una fuga en cuanto alguien refactorice. Segunda, y más útil todavía en la práctica: cuenta tus raíces. Una aplicación sana tiene unas pocas —la del arranque, la de cada modal, la de cada elemento de una lista virtualizada—, y todas se pueden nombrar. Si un proyecto tiene cincuenta raíces repartidas, casi seguro que se están usando para esquivar un problema de estructura del árbol de componentes, y el remedio correcto es arreglar la estructura, no seguir abriendo salidas de emergencia.

⚔️ Construye un modal con vida propia
  1. Crea una raíz desde dentro de un efecto y comprueba que sobrevive al desecho del efecto.
  2. Verifica que las lecturas dentro de la raíz no suscriben al efecto exterior.
  3. Añade un temporizador dentro de la raíz y comprueba que sigue disparando tras desechar el efecto.
  4. Deshecha la raíz y confirma que el temporizador se cancela y que el árbol queda vacío.