Qué es un memo por dentro
Un memo no es una caché con clave: es un nodo del grafo que es fuente y computación a la vez, con evaluación perezosa e invalidación automática. La implementación completa, campo a campo.
La palabra memoización arrastra el significado que tiene en programación funcional: una tabla que asocia argumentos con resultados. Un memo reactivo no es eso en absoluto. No tiene argumentos, no tiene tabla y no tiene política de expulsión. Es un nodo del grafo con una caché de un solo hueco que se invalida sola cuando alguna de sus dependencias cambia. Entender esa diferencia evita media docena de errores de intuición.
- Implementar un memo completo sobre el modelo push-pull.
- Distinguir memoización reactiva de memoización por argumentos.
- Explicar por qué la caché tiene un solo hueco y no necesita expulsión.
- Ver el memo como el nodo que hereda las dos caras del grafo.
La implementación completa
Con toda la maquinaria de los niveles anteriores, un memo cabe en muy poco.
function memo(fn, opciones = {}) {
const nodo = {
fn,
valor: undefined,
estado: SUCIO, // nace sin calcular
fuentes: new Set(), // de quien leo
observadores: new Set(), // quien me lee
iguales: opciones.iguales ?? Object.is,
};
registrar(nodo); // el duenno actual me posee, nivel 7
return () => {
if (nodo.estado !== LIMPIO) actualizar(nodo); // pull: resuelvo si hace falta
seguir(nodo); // me registro con quien me lee
return nodo.valor;
};
}
Tres líneas en el getter, y cada una hace una cosa distinta. La primera resuelve el estado, que puede implicar preguntar hacia arriba y quizá ejecutar el cuerpo. La segunda teje la arista con quien está leyendo, que es lo que convierte al memo en fuente. La tercera devuelve la caché.
El orden importa. actualizar antes que seguir porque actualizar cambia el observador actual mientras ejecuta, y seguir tiene que usar el observador externo, el que estaba leyendo.
No es memoización por argumentos
La diferencia con la memoización clásica es estructural y conviene enumerarla.
No hay argumentos. Un memo no es una función que se llama con parámetros: es una expresión sin parámetros cuyas entradas son las señales que lee. No hay nada que usar como clave.
La caché tiene un solo hueco. Solo existe un valor válido en cada instante: el correspondiente al estado actual de las fuentes. No hay que decidir qué expulsar porque no hay nada que acumular.
La invalidación es exacta y automática. Una caché clásica se invalida por tiempo, por tamaño o a mano, y siempre con el riesgo de servir algo obsoleto. Un memo se invalida exactamente cuando alguna de sus entradas cambia, ni antes ni después, porque el grafo lo sabe.
Es perezoso. Se crea sin calcular y no se evalúa hasta que alguien lo lee. Si nadie lo lee nunca, su cuerpo no corre nunca, aunque sus fuentes cambien mil veces.
const caro = memo(() => procesarTodo(datos()));
// el cuerpo NO ha corrido todavia
datos.set(nuevos); // sigue sin correr, solo se marca
datos.set(otros); // sigue sin correr
caro(); // ahora si, una sola ejecucion con el ultimo valor
Que las tres escrituras del ejemplo no ejecuten nada no es una optimización menor: es lo que permite escribir en una fuente cientos de veces por segundo sin coste. El precio se paga en la lectura, una sola vez, con el último valor. Es exactamente la propiedad que hace que un motor push-pull tolere entradas de alta frecuencia como un puntero o un scroll.
Fuente y computación a la vez
Vuelve a mirar la estructura. Un memo tiene fuentes y observadores, fn y valor. Es literalmente la unión de las dos formas del nivel 2, y esa doble naturaleza tiene consecuencias visibles.
Como computación, se ensucia cuando sus fuentes cambian, se reejecuta, y desata y vuelve a tejer sus aristas en cada ejecución.
Como fuente, se puede leer desde cualquier parte, registra a quien la lee, y notifica a sus observadores cuando su valor cambia.
Es esa segunda cara la que lo distingue de un simple valor cacheado, y de ella sale la propiedad más útil del memo: puede cortar la propagación. Si al reejecutarse produce el mismo valor que antes, no notifica a nadie, y todo lo que colgaba de él se queda sin recalcular. Es el tema de la lección 3.
flowchart LR F[fuentes que lee] --> M[memo] M --> O[observadores que lo leen] M -.cara de computacion.-> C[tiene fn y se ensucia] M -.cara de fuente.-> V[tiene valor y notifica] style F fill:#89b4fa,color:#11111b style M fill:#cba6f7,color:#11111b style O fill:#a6e3a1,color:#11111b style C fill:#94e2d5,color:#11111b style V fill:#94e2d5,color:#11111b
Variantes y vocabulario
El valor anterior como argumento
Un detalle de implementación con más profundidad de la que parece: muchos motores pasan el valor anterior al cuerpo del memo.
function memo(fn, inicial, opciones = {}) {
const nodo = { fn, valor: inicial, /* ... */ };
// en ejecutar: const v = nodo.fn(nodo.valor);
}
// Uso: un acumulador reactivo
const maximo = memo(prev => Math.max(prev, medicion()), -Infinity);
Esto convierte al memo en un pliegue sobre la historia de sus dependencias, no solo una función de su estado actual. Es potente y es peligroso: el valor deja de ser función pura del estado, así que dos ejecuciones desde el mismo estado global pueden dar resultados distintos según por dónde se haya pasado. Se pierde la independencia de la ruta.
Úsalo cuando de verdad necesites acumular, y sé consciente de que estás introduciendo estado histórico en un sistema que por lo demás es una función del presente.
El cambio de modelo mental que hace clic es dejar de ver el memo como una optimización y verlo como un elemento estructural del grafo. Un memo no está ahí para hacer que algo vaya más rápido: está ahí para introducir un punto donde la propagación puede detenerse. Compara con qué pasa si lo quitas y sustituyes su cuerpo por una función normal llamada desde cada consumidor. El cálculo se repite una vez por consumidor, sí, pero eso es lo de menos; lo grave es que cada consumidor teje aristas directas con todas las fuentes, así que el grafo se aplana y pierde el punto de corte. Un cambio en cualquier fuente notifica a todos los consumidores directamente, sin que nadie pueda decir el valor derivado no cambió, quedaos quietos. Con el memo, el grafo tiene una cintura, y esa cintura es una válvula. Por eso la pregunta correcta al decidir si poner un memo no es cuánto cuesta el cálculo —que es la pregunta que casi todo el mundo se hace— sino cuántas aristas ahorra y cuántas veces va a poder cortar. Un cálculo carísimo leído por un solo consumidor y cuyo valor cambia siempre no gana nada con un memo. Un cálculo trivial leído por doscientos consumidores y cuyo valor casi nunca cambia lo gana todo. La lección 5 pone números a esto.
El nombre en cada motor
createMemo en Solid. computed en Vue, en Angular y en Preact Signals. $derived en Svelte, que es la misma idea expresada por el compilador. Signal.Computed en la propuesta de TC39. Todos son el mismo nodo con la misma doble naturaleza; las diferencias están en la sintaxis y en detalles de planificación, no en la estructura.
- Implementa el memo de esta lección sobre un motor push-pull.
- Comprueba con un contador que el cuerpo no corre hasta la primera lectura.
- Escribe la fuente diez veces sin leer y verifica que el cuerpo corrió una sola vez al leer.
- Añade un segundo consumidor y comprueba que la segunda lectura no ejecuta el cuerpo otra vez.