Saltar al contenido

Derivado como función suelta vs memo

Una función que lee signals ya es un derivado reactivo: se inyecta en quien la llama, sin caché ni nodo propio. Un memo añade caché, identidad estable y corte de propagación a cambio de un nodo con coste fijo. La pregunta profesional no es si usar memo, sino si el cacheo paga su precio aquí.

Una función es un derivado transparente

La idea que descoloca a casi todos: una función normal que lee signals ya es reactiva. No necesita envoltura. Cuando llamas a doble() dentro de un efecto o del JSX, Solid rastrea el n() que hay en su cuerpo y suscribe al consumidor a n. La función no crea un nodo propio en el grafo: inyecta sus dependencias en el ámbito que la invoca, como si su cuerpo estuviera escrito ahí mismo.

TSX
const [n, setN] = createSignal(1);const doble = () => n() * 2; // reactiva, sin nodo, sin cachecreateEffect(() => console.log(doble())); // el efecto depende de n

Llamo a esto un derivado transparente: el grafo ve a través de la función hasta sus fuentes. No hay barrera intermedia. Esto tiene una virtud —aplana el grafo, no añade indirección, se lee como una fórmula— y un precio: no cachea. Si el mismo doble() aparece en diez lugares rastreados, se evalúa diez veces por cada cambio de n, porque cada lectura reinyecta y reejecuta su cuerpo.

Esa reinyección muerde de una forma sutil en grafos con rombos. Si una función suelta la leen dos memos distintos, su cuerpo se evalúa una vez por cada uno: la transparencia no comparte trabajo entre ramas. Donde un memo pondría un único nodo del que ambas ramas tiran, la función se recomputa en cada rama, multiplicando el cálculo por el número de lectores rastreados.

TSX
const total = () => items().reduce((a, i) => a + i.valor, 0); // funcionconst conIva = createMemo(() => total() * 1.21); // evalua total() una vezconst conDto = createMemo(() => total() * 0.9);  // evalua total() OTRA vez// total() recorre items dos veces por cada cambio: una por memo

El memo es un nodo opaco con caché

createMemo es lo contrario: crea un nodo real en el grafo, con memoria propia. Cachea su resultado y lo recomputa una sola vez por cambio de fuente, sin importar cuántos lo lean. Aguas abajo, los consumidores ven el nodo memo, no sus fuentes: es un derivado opaco que interpone una barrera.

TSX
const doble = createMemo(() => n() * 2); // nodo con cachecreateEffect(() => console.log(doble())); // depende del memo, no de n directamente

Esa opacidad le da tres poderes que la función no tiene. Cachea, así que N lectores comparten una única recomputación. Ofrece identidad estable: devuelve la misma referencia hasta que su contenido cambia, lo que un <For> o un children aguas abajo necesitan para no rehacer trabajo. Y corta la propagación vía equals: si recomputa y el resultado no cambió, no despierta a nadie.

El poder de la identidad estable es el más fácil de infravalorar. Una función que devuelve items().filter(...) fabrica un array nuevo en cada lectura; un <For> que lo reciba ve una referencia distinta cada vez y remonta filas sin motivo. El memo estabiliza esa referencia y el <For> conserva las filas que no cambiaron:

TSX
// La referencia estable del memo evita que el For remonte filas:const visibles = createMemo(() => items().filter((i) => i.activo));<For each={visibles()}>{(i) => <Fila dato={i} />}</For>

El corte de propagación es el tercer poder y el más silencioso. Cuando un memo recomputa y su equals juzga que el resultado no cambió, la actualización muere en el nodo: los observadores ni se enteran. Una función suelta no puede ofrecer esto porque no tiene dónde recordar el valor anterior con el que comparar; recomputar es lo único que sabe hacer. Por eso, cuando una fuente ruidosa produce un valor derivado que rara vez cambia, el memo no solo cachea: actúa como una compuerta que absorbe el ruido y solo deja pasar las transiciones reales.

El cálculo del coste: lectores por tick

Un memo no es gratis: ocupa memoria, una entrada en el grafo y una indirección. Solo compensa cuando evita más trabajo del que cuesta, y dos variables lo deciden. Una es el coste del cálculo: ordenar mil filas duele si se repite; el memo lo hace una vez por cambio. La otra, la que casi todos ignoran, es el número de lectores en el mismo tick: con un único lector, memo y función recomputan lo mismo, una vez por cambio, y el memo solo añade el coste de su nodo.

Ponle números y el cruce se vuelve nítido. Si ordenar cuesta dos milisegundos y lo leen cinco vistas en el mismo fotograma, la función gasta diez milisegundos por cambio y el memo dos: una quinta parte. Con un solo lector, ambos gastan dos y el memo solo suma el mantenimiento de su nodo, así que no gana nada. Cuantos más lectores compartan la derivación en un mismo tick y más caro sea el cálculo, más rentable es reificarla en un memo; con pocos lectores y cálculo barato, el memo es lastre puro.

TSX
const f = () => [...items()].sort(caro);             // funcion: ordena por lecturaconst m = createMemo(() => [...items()].sort(caro)); // memo: ordena por cambio// Cinco lectores en un mismo tick:// f -> ordena cinco veces; m -> ordena una y sirve el mismo resultado cinco

La misma fórmula, dos regímenes de coste opuestos según cuántos la miren a la vez. Ese “a la vez” —lectores en el mismo tick, no en total a lo largo del tiempo— es la variable que casi nadie cuenta y la que de verdad decide.

La regla mental: el beneficio del memo es, más o menos, el coste del cálculo por los lectores menos uno, restado el coste fijo del nodo. Un arr().length leído tres veces no justifica un memo; un arr().filter(pesado).sort() leído tres veces, sí. Y hay una tercera razón que no va de velocidad sino de referencia: si aguas abajo alguien decide actuar solo cuando la referencia cambia —un <For>, un efecto que compara por identidad— necesitas la referencia estable que solo el memo da. Una función entrega un objeto nuevo en cada lectura y sabotea ese === de raíz.

Una función dentro de un memo

El corolario que evita la sobreingeniería: no todo derivado intermedio merece su propio memo. Dentro del cuerpo de un memo puedes llamar a funciones auxiliares con total libertad; se inyectan en el rastreo del memo que las contiene, sin añadir nodos. Un cálculo barato usado una sola vez dentro de una computación debe ser una función, no un memo.

TSX
const normalizar = (s: string) => s.trim().toLowerCase(); // funcion, no memoconst busqueda = createMemo(() => {  const q = normalizar(consulta()); // se inyecta en el rastreo del memo  return items().filter((i) => normalizar(i.nombre).includes(q));});

Memoizar normalizar aquí sería puro lastre: un nodo extra para un cálculo trivial que solo consume el memo de arriba. La heurística es nítida: memoiza donde el resultado se comparte entre varios lectores o donde necesitas identidad o corte de propagación; deja como función suelta todo lo que sea un paso intermedio barato dentro de una sola computación.

Da la vuelta a la pregunta y decide en un segundo. En vez de ¿esto debería ser un memo?, pregunta ¿cuántas computaciones distintas leerán este valor en el mismo tick, y a alguna le importa su identidad de referencia?. Si la respuesta es “una, y no”, es una función. Si es “varias” o “sí”, es un memo. Ese giro convierte una decisión que muchos toman por superstición en una lectura directa de la topología de tu grafo, y te ahorra tanto el memo de más como el recálculo repetido de menos.