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í.
Tienes un valor que depende de signals. La forma más ligera de expresarlo es una función normal que los lee; la más potente, un createMemo. Entre ambas no hay una jerarquía de calidad sino una frontera de coste: la función es transparente y se inyecta en quien la llama, el memo es un nodo opaco que cachea. Elegir bien es entender qué le hace cada una al grafo, no aplicar un reflejo de memoizar por si acaso.
- Ver una función que lee signals como un derivado que se inyecta en el llamador.
- Contrastarla con el memo: caché, identidad estable y corte de propagación.
- Calcular cuándo el cacheo compensa según lectores por tick y coste.
- Reconocer cuándo una función dentro de un memo es la opción correcta.
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.
const [n, setN] = createSignal(1);
const doble = () => n() * 2; // reactiva, sin nodo, sin cache
createEffect(() => 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.
const total = () => items().reduce((a, i) => a + i.valor, 0); // funcion
const conIva = createMemo(() => total() * 1.21); // evalua total() una vez
const conDto = createMemo(() => total() * 0.9); // evalua total() OTRA vez
// total() recorre items dos veces por cada cambio: una por memo
Una función suelta no tiene dependencias propias: las presta al efecto o memo que la invoca. Por eso la misma función es reactiva en un contexto rastreado y un cálculo muerto fuera de él. No hay estado escondido en la función; hay una computación llamante que registra los signals leídos durante su ejecución, y la función solo aporta las lecturas. Comprender esto disuelve la pregunta de dónde vive la reactividad de un derivado suelto: no vive en él, vive en quien lo lee.
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.
const doble = createMemo(() => n() * 2); // nodo con cache
createEffect(() => 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:
// 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.
flowchart TD subgraph Funcion transparente SA[signal fuente] --> LA[lector 1 recomputa] SA --> LB[lector 2 recomputa] end subgraph Memo opaco SB[signal fuente] --> M[memo un calculo cacheado] M --> LC[lector 1] M --> LD[lector 2] end style SA fill:#89b4fa,color:#11111b style SB fill:#89b4fa,color:#11111b style M fill:#cba6f7,color:#11111b
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.
const f = () => [...items()].sort(caro); // funcion: ordena por lectura
const 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.
Función suelta
const doble = () => n() * 2. Se inyecta en quien la llama, sin nodo ni caché. Barata; recomputa una vez por cada lectura rastreada.
Memo
createMemo(() => n() * 2). Nodo con caché, identidad estable y corte de propagación. Una recomputación por cambio, la lean los que la lean.
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.
const normalizar = (s: string) => s.trim().toLowerCase(); // funcion, no memo
const 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.
La tentación de intermedio es rodear cada derivación de un createMemo buscando rendimiento. Casi siempre empeora: llenas el grafo de nodos que cachean valores triviales leídos una sola vez, pagando memoria e indirección a cambio de nada. El memo rinde cuando concurre una de tres condiciones concretas —cálculo caro compartido por varios lectores, necesidad de identidad estable, o corte de propagación—. Fuera de ellas, una función suelta es más rápida de crear, más ligera en el grafo y más honesta sobre lo que es: una fórmula, no un nodo de estado.
La distinción profunda entre estas dos formas no es de rendimiento sino de ontología: qué clase de cosa es tu valor derivado dentro del grafo. Una función que lee signals es transparente y efímera: no existe como entidad, existe solo el acto de calcularla, y ese acto se inyecta allí donde lo invocas, aplanando el grafo y reinyectando sus dependencias en cada lector. Es la elección por defecto porque es la más barata y la más veraz para una simple fórmula. Un memo, en cambio, es una entidad persistente: ocupa un lugar en el grafo, tiene identidad de referencia estable en el tiempo, cachea su resultado y decide con equals cuándo propagar. Convertir una función en memo no es acelerarla, es reificarla: darle existencia como nodo con memoria e identidad. Y esa reificación solo se justifica por tres motivos precisos —compartir un cálculo caro entre lectores del mismo tick, ofrecer una referencia estable a un consumidor que compara por identidad, o cortar la propagación cuando el resultado no cambia—. Fuera de ellos, reificar es engordar el grafo sin ganar nada. El profesional razona con este modelo de coste explícito y sabe que la respuesta por defecto a cómo derivo esto es una función; sube a memo solo cuando puede nombrar cuál de las tres razones aplica. Quien memoiza por reflejo confunde tener muchos nodos con ir rápido; quien entiende la frontera mantiene su grafo tan pequeño como pueda y tan rápido como deba.
- Escribe una derivación cara como función y léela en tres sitios rastreados; cuenta las ejecuciones por cada cambio de fuente.
- Conviértela en
createMemoy vuelve a contar: deberías ver una sola por cambio, la lean los que la lean. - Hazla trivial —
() => items().length— y con un solo lector; argumenta por qué un memo aquí solo añade lastre. - Pásala a un
<For>primero como función y luego como memo; observa en las devtools qué filas se remontan y cuáles se conservan. - Mete una función auxiliar barata dentro del cuerpo de un memo y confirma que no necesita su propio nodo para ser reactiva.