wandres.dev
MEMOIZACIÓN · Cachés que se invalidan solas

Pereza, caché y el memo que nadie lee

Un memo sin observadores no se evalúa aunque sus fuentes cambien, y eso plantea dos preguntas incómodas: cuándo se recalcula el trabajo acumulado y qué pasa con las aristas de un nodo que lleva tiempo sin ejecutarse.

⏱ 16 min

La pereza de un memo es su propiedad más útil y la que produce los comportamientos más desconcertantes. Un memo cuyo valor nadie pide no se evalúa jamás, con independencia de cuántas veces cambien sus entradas. Eso ahorra muchísimo trabajo y, a cambio, introduce tres cuestiones que hay que entender: cuándo se paga el trabajo acumulado, si las aristas siguen vivas mientras el nodo duerme, y qué pasa con los efectos secundarios que alguien haya metido en el cuerpo.

🎯 Al terminar esta lección sabrás
  • Trazar el comportamiento de un memo sin observadores durante muchos cambios.
  • Explicar por qué el coste diferido es de un solo recálculo y no de muchos.
  • Determinar si las aristas de un memo dormido siguen consumiendo recursos.
  • Reconocer por qué un efecto secundario dentro de un memo es un error.

El memo que duerme

Sigue este caso con atención, porque es donde la pereza se ve mejor.

const datos = senal([]);
let evaluaciones = 0;
const resumen = memo(() => { evaluaciones++; return calcular(datos()); });

for (let i = 0; i < 1000; i++) datos.set(generar(i));
console.log(evaluaciones);   // 0

console.log(resumen());      // se evalua ahora
console.log(evaluaciones);   // 1

Mil escrituras, cero evaluaciones. Al leer, una sola, con el último valor. El trabajo diferido no se acumula: se colapsa.

Esta propiedad tiene un nombre útil: el coste de un memo dormido es O(1) en el número de cambios, no O(n). Es lo que permite tener un grafo enorme con la mayor parte dormida y despertarla al abrir un panel sin pagar el historial.

ℹ️
Colapsar no es lo mismo que perder

El trabajo se colapsa porque un memo es función de sus entradas actuales, no de la secuencia. Si el memo fuera un acumulador —con el valor anterior como argumento, como vimos en la lección 1— el colapso sería incorrecto: perderías novecientos noventa y nueve pasos del pliegue. Por eso, un memo acumulador no puede ser perezoso sin cambiar su semántica, y esa es una razón de peso para preferir formulaciones que sean función del presente.

Qué mantiene vivo un memo dormido

La pregunta natural es si el memo dormido sigue costando algo. La respuesta tiene matices y son interesantes.

Sus aristas siguen existiendo. El memo está en la lista de observadores de sus fuentes, así que cada escritura en esas fuentes lo recorre para marcarlo. Ese coste es real: una asignación de entero por escritura, más el recorrido de la lista.

Pero el marcado se corta enseguida. Recuerda la guarda: si el nodo ya está marcado, no se vuelve a marcar ni se propaga hacia abajo. Así que el memo dormido cuesta una comprobación por escritura y nada más, con independencia de cuántos cambios haya.

Su valor cacheado sigue en memoria. Si el último valor que calculó era un array de cien mil elementos, ese array sigue retenido. Un panel cerrado puede estar reteniendo memoria significativa sin hacer nada. Es un coste que casi nadie contabiliza.

Sus fuentes lo mantienen vivo. Mientras esté en la lista de observadores de una fuente viva, no se puede recolectar. Solo desaparece cuando lo desecha su dueño, que es el nivel 7.

// El memo dormido cuesta esto por cada escritura en sus fuentes:
if (obs.estado >= estado) continue;   // una comparacion, y salimos

Cuándo la pereza sorprende

Tres situaciones donde el comportamiento perezoso choca con la expectativa.

El memo que nunca corre. Si nadie lee un memo, su cuerpo no se ejecuta nunca. Si alguien metió ahí una llamada a una API, un registro o una inicialización, no ocurre. Y no ocurre de forma silenciosa.

El memo que corre tarde. Al abrir un panel cerrado durante minutos, el memo se evalúa en ese instante. Si su cuerpo es caro, el coste aparece justo en el momento de la interacción, que es el peor momento posible desde el punto de vista de la latencia percibida. Un memo perezoso puede convertir un trabajo repartido en un pico concentrado.

El memo que corre menos veces de lo esperado. Con dos consumidores que lo leen en momentos distintos del mismo ciclo, el cuerpo corre una vez y los dos ven el mismo valor. Eso es lo correcto, y sorprende a quien esperaba una evaluación por lectura.

flowchart TB
E[mil escrituras en la fuente] --> M[memo marcado mil veces]
M --> C[coste una comparacion por escritura]
M --> L[primera lectura]
L --> U[una unica evaluacion con el ultimo valor]
M --> R[el valor cacheado anterior sigue en memoria]
style E fill:#89b4fa,color:#11111b
style M fill:#cba6f7,color:#11111b
style C fill:#a6e3a1,color:#11111b
style L fill:#f9e2af,color:#11111b
style U fill:#a6e3a1,color:#11111b
style R fill:#f9e2af,color:#11111b

Por qué un efecto secundario en un memo es un error

De todo lo anterior se deduce la regla más importante sobre memos, y conviene justificarla bien porque se incumple constantemente.

Un memo se evalúa un número impredecible de veces: cero si nadie lo lee, una si lo leen mil consumidores en el mismo ciclo, y en un momento arbitrario respecto a cualquier otra cosa. Por tanto, cualquier efecto secundario dentro de su cuerpo ocurrirá un número impredecible de veces en un momento impredecible.

// MAL: efecto secundario en un memo
const usuario = memo(() => {
  registrarAnalitica('usuario_calculado');   // cuantas veces? cuando?
  localStorage.setItem('ultimo', id());       // puede no ocurrir nunca
  return buscarUsuario(id());
});

// BIEN: el memo deriva, el efecto actua
const usuario = memo(() => buscarUsuario(id()));
efecto(() => {
  registrarAnalitica('usuario_calculado');
  localStorage.setItem('ultimo', id());
});

La separación no es una convención estética: es la consecuencia directa de que los memos sean perezosos y los efectos ansiosos. Un efecto está garantizado que corre cuando sus dependencias cambian; un memo no. Poner algo en el sitio equivocado significa aceptar la garantía equivocada.

La pereza convierte al grafo en un presupuesto, no en un gasto

El cambio de perspectiva que produce entender la pereza es este: el grafo reactivo no describe trabajo que se va a hacer, describe trabajo que se hará si hace falta. Es una declaración de intenciones, no un plan de ejecución. Eso tiene una consecuencia práctica enorme para el diseño de aplicaciones grandes: puedes construir un grafo mucho más rico de lo que jamás vas a evaluar, sin pagar por ello. Un panel de administración con cuarenta paneles derivados, de los que un usuario típico abre dos, paga la construcción de cuarenta nodos y la evaluación de dos. La construcción es barata —un objeto y unas aristas— y la evaluación, que es lo caro, se paga bajo demanda. Esto es exactamente lo contrario del modelo de reconciliación, donde describir la vista es ejecutarla y por tanto todo lo que declaras lo pagas. Y sugiere un estilo de diseño distinto que mucha gente no aprovecha: declara derivaciones con generosidad y confía en la pereza, en vez de escribir lógica condicional para evitar calcular cosas. El motor ya sabe no calcular lo que nadie mira, y lo sabe con más precisión que tú. La única disciplina que exige a cambio es la de esta lección: nada de efectos secundarios en los cuerpos, porque en un presupuesto no se puede gastar.

⚔️ Explora los limites de la pereza
  1. Crea un memo con un contador y escribe su fuente mil veces sin leerlo. Comprueba que el contador sigue a cero.
  2. Mide el tiempo de esas mil escrituras y compáralo con las mismas escrituras si el memo tiene un efecto que lo lee.
  3. Haz que el memo devuelva un array grande, léelo una vez y comprueba con un snapshot de memoria que sigue retenido.
  4. Mete una llamada a console.log en un memo con dos consumidores y cuenta cuántas veces aparece por ciclo.