wandres.dev
REACTIVIDAD PROFUNDA · Proxies y el estado anidado

Instrumentación perezosa: solo lo que se lee

Envolver un objeto entero de golpe es inviable en estructuras grandes. La técnica que usan los motores reales: crear el proxy de cada nivel solo al atravesarlo, con caché para preservar la identidad.

⏱ 17 min

Hacer reactivo un objeto de diez mil nodos envolviéndolo entero costaría diez mil proxies antes de que nadie lea nada. La solución que usan todos los motores con estado profundo es no envolver nada por adelantado: crear el proxy de cada nivel en el momento en que alguien lo atraviesa, y solo entonces. Es una idea sencilla con tres complicaciones que hay que resolver bien para que no produzca bugs.

🎯 Al terminar esta lección sabrás
  • Implementar la envoltura perezosa por niveles.
  • Resolver la identidad de los proxies con una caché.
  • Explicar por qué un objeto nunca leído no cuesta nada.
  • Reconocer los tres problemas que introduce la pereza.

Envolver al atravesar

La versión ingenua de la lección anterior crea un proxy nuevo en cada lectura de un objeto anidado. Eso es correcto y tiene dos problemas: cuesta una asignación por lectura, y cada proxy es distinto, así que comparar por identidad falla.

estado.usuario === estado.usuario   // false con la version ingenua

La solución es una caché de proxies, con un WeakMap para no impedir la recolección del objeto original.

const cache = new WeakMap();

function reactivo(objetivo) {
  if (typeof objetivo !== 'object' || objetivo === null) return objetivo;
  const existente = cache.get(objetivo);
  if (existente) return existente;

  const fuentes = new Map();
  const fuenteDe = (clave) => {
    let f = fuentes.get(clave);
    if (!f) { f = { observadores: new Set() }; fuentes.set(clave, f); }
    return f;
  };

  const p = new Proxy(objetivo, {
    get(obj, clave, receptor) {
      seguir(fuenteDe(clave));
      return reactivo(Reflect.get(obj, clave, receptor));   // envuelve al atravesar
    },
    set(obj, clave, valor, receptor) {
      const anterior = obj[clave];
      const bruto = crudo(valor);                            // nunca guardar proxies dentro
      const ok = Reflect.set(obj, clave, bruto, receptor);
      if (ok && !Object.is(anterior, bruto)) marcar(fuenteDe(clave), SUCIO);
      return ok;
    },
  });
  cache.set(objetivo, p);
  return p;
}

const crudos = new WeakMap();
function crudo(v) { return crudos.get(v) ?? v; }

Ahora estado.usuario === estado.usuario es cierto, porque las dos lecturas devuelven el mismo proxy de la caché.

⚠️
Nunca guardes un proxy dentro del objeto original

Si al escribir guardas el proxy en lugar del objeto crudo, acabas con proxies de proxies: cada acceso atraviesa dos capas de trampas, la identidad se vuelve impredecible y la serialización produce estructuras extrañas. Todos los motores con estado profundo tienen una función para obtener el objeto crudo de un proxy —toRaw en Vue, unwrap en Solid, $state.snapshot en Svelte— y la usan internamente en cada escritura.

Qué cuesta un objeto nunca leído

Con la envoltura perezosa, la respuesta es exactamente cero.

const estado = reactivo({ visible: { a: 1 }, oculto: cargarDiezMilNodos() });

efecto(() => usar(estado.visible.a));
// proxies creados: el de la raiz y el de visible. Dos.
// El subarbol de oculto no se ha tocado: cero proxies, cero fuentes.

Esta propiedad es lo que hace viable el modelo. Un almacén con el estado completo de una aplicación —cientos de kilobytes de JSON— cuesta un proxy al crearse, y a partir de ahí paga solo por los caminos que se recorren de verdad.

Y como la mayoría de las aplicaciones leen una fracción minúscula de su estado en cada pantalla, el coste real es una fracción minúscula del teórico. Es exactamente el mismo argumento que la pereza del memo en el nivel 6, aplicado a la estructura en vez de al cálculo.

flowchart TB
R[objeto raiz] --> V[rama leida]
R -.nunca leida.-> O[rama de diez mil nodos]
V --> H[hoja leida]
R2[proxy de la raiz] --> V2[proxy de la rama]
V2 --> H2[fuente de la hoja]
style R fill:#89b4fa,color:#11111b
style V fill:#89b4fa,color:#11111b
style H fill:#89b4fa,color:#11111b
style O fill:#f9e2af,color:#11111b
style R2 fill:#cba6f7,color:#11111b
style V2 fill:#cba6f7,color:#11111b
style H2 fill:#a6e3a1,color:#11111b

Los tres problemas de la pereza

El primero: las fuentes también son perezosas y eso complica el borrado. Una fuente se crea al leer una propiedad por primera vez. Si nadie la ha leído nunca y alguien la escribe, no hay fuente que marcar, lo cual está bien porque tampoco hay observadores. Pero hay que tener cuidado de que la escritura cree la fuente si va a haber lectores después, o bien de que la lectura posterior no dependa de una fuente que no existía. La forma limpia es que fuenteDe cree siempre, tanto en lectura como en escritura.

El segundo: la identidad depende del orden de acceso. Si un objeto se lee primero a través del proxy, la caché guarda su envoltorio. Si se lee primero de forma cruda y se compara después con la versión del proxy, la comparación falla. En una base de código donde conviven accesos crudos y reactivos, esto produce bugs que dependen del orden de ejecución.

El tercero: no se puede saber qué es reactivo mirando el código. Un valor obtenido de un proxy es reactivo; el mismo valor obtenido del objeto crudo no lo es, y son indistinguibles por su tipo. Es el coste de depurabilidad que mencionábamos, y no tiene solución dentro del modelo; solo se mitiga con convenciones de nomenclatura y con herramientas de desarrollo.

La variante de solo lectura

Solid toma una decisión distinta que merece explicarse porque resuelve parte de estos problemas: su almacén devuelve un proxy de solo lectura, y las mutaciones se hacen a través de una función de actualización.

// Esquema del modelo de Solid, simplificado
const [estado, actualizar] = crearAlmacen({ usuario: { nombre: 'Ana' } });

estado.usuario.nombre;                       // lectura, teje la arista
actualizar('usuario', 'nombre', 'Eva');      // escritura por camino explicito

La ventaja es que todas las escrituras pasan por un único punto, lo que permite agrupar automáticamente, aplicar la escritura sobre el objeto crudo sin riesgo de anidar proxies, y ofrecer utilidades de actualización estructural. La desventaja es una sintaxis de escritura menos natural que la asignación directa.

Vue toma la decisión contraria: la asignación funciona directamente sobre el proxy, lo que es más ergonómico, y a cambio el motor tiene que agrupar por su cuenta con su planificador asíncrono.

La pereza estructural convierte el coste de un modelo en el coste de su uso

Merece la pena apreciar la envergadura de lo que consigue esta técnica, porque es la misma idea que aparece en tres sitios distintos de este track y en muchos otros sistemas. El coste deja de ser proporcional al tamaño del modelo y pasa a ser proporcional al uso del modelo, y esa transformación es lo que permite que un motor reactivo escale a estructuras grandes. La misma idea está en la pereza del memo del nivel 6: un nodo que nadie lee no se evalúa. Está en las dependencias condicionales del nivel 3: una rama que no se ejecuta no teje aristas. Y está aquí: un subárbol que nadie recorre no se instrumenta. Las tres son el mismo principio aplicado a tres dimensiones distintas —cálculo, dependencias y estructura—, y juntas son la razón por la que un motor de grano fino puede manejar un estado de aplicación completo sin que el coste crezca con él. Hay un corolario práctico que conviene interiorizar y que la gente aprovecha poco: puedes permitirte modelar tu estado con generosidad. Un almacén con toda la información de la aplicación, incluidas ramas que la mayoría de los usuarios no visitarán, cuesta lo que cuesten los caminos que se recorren. La restricción real no es el tamaño del modelo sino la anchura del uso, y esa distinción cambia bastante cómo se diseña una aplicación grande.

⚔️ Mide la pereza
  1. Implementa la envoltura perezosa con caché y un contador de proxies creados.
  2. Construye un objeto de diez mil nodos y lee una sola hoja profunda. Cuenta los proxies.
  3. Comprueba que dos lecturas del mismo objeto anidado devuelven la misma referencia.
  4. Guarda un proxy dentro del objeto original a propósito y describe qué se rompe al leerlo después.