Memo vs función simple vs signal
Tres formas de tener un valor derivado en Solid: una función normal, un createMemo o un signal sincronizado a mano. Cuándo el cacheo compensa —cálculo caro, muchos lectores, identidad estable— y cuándo solo añade un nodo inútil.
Tienes un valor que depende de otros. Solid te ofrece tres caminos para expresarlo, y elegir mal cuesta rendimiento o corrección. Una función que lee signals ya es reactiva; un memo le añade caché e identidad; un signal sincronizado a mano es casi siempre un error. La pregunta profesional no es “¿uso memo?” sino “¿el cacheo paga su propio coste aquí?”.
- Distinguir las tres formas de derivar un valor.
- Calcular cuándo el cacheo de un memo compensa su coste.
- Valorar la identidad estable como razón para memoizar.
- Reconocer el anti-patrón de sincronizar estado derivado en un signal.
Tres formas de derivar
Función simple
const doble = () => n() * 2. Recomputa en cada llamada. Sin caché, sin nodo en el grafo. Barata de crear, cara si se lee mucho o el cálculo pesa.
createMemo
const doble = createMemo(() => n() * 2). Cachea el resultado, recomputa una vez por cambio de fuente sin importar cuántos lo lean. Un nodo real con coste fijo de memoria.
Signal a mano
Un createSignal que un efecto mantiene sincronizado. Duplica la fuente de la verdad y abre desincronizaciones. Casi siempre, un error.
La clave contraintuitiva viene de la lección anterior: la función simple también es reactiva. Al leer doble() dentro de un efecto o del JSX, Solid rastrea el n() que hay dentro y suscribe al consumidor. La función no crea un nodo propio: inyecta sus dependencias en el ámbito que la llama, como si su cuerpo estuviera escrito ahí mismo.
const [n, setN] = createSignal(1);
const doble = () => n() * 2; // reactiva, sin caché
createEffect(() => console.log(doble())); // el effect depende de n
Esa inyección de dependencias tiene una virtud: la función aplana el grafo. No añade un nodo intermedio, así que para derivaciones triviales es más ligera que un memo y se lee como lo que es, una fórmula. El precio es que no cachea: si el mismo doble() aparece en diez lugares rastreados, se evalúa diez veces por cada cambio de n. Vistas lado a lado, las tres formas revelan su carácter:
const f = () => n() * 2; // recomputa en cada lectura
const m = createMemo(() => n() * 2); // recomputa por cambio, y cachea
const [s, setS] = createSignal(n() * 2); // NO se actualiza solo
Fíjate en la tercera: nace desincronizada. s vale el doble de n solo en el instante de crearse; en cuanto n cambie, s miente hasta que alguien la reescriba a mano. Ese es el defecto de fondo del signal derivado a mano, y ninguna cantidad de efectos lo cura del todo.
El cálculo del coste
Un memo no es gratis: ocupa memoria, ocupa una entrada en el grafo y añade una indirección. Solo compensa cuando evita más trabajo del que cuesta. Dos factores mandan.
- Coste del cálculo. Si derivar es caro —ordenar un array, filtrar mil filas, parsear— recomputarlo en cada lectura duele. El memo lo hace una vez por cambio.
- Número de lectores. Con un solo lector, memo y función recomputan lo mismo: una vez por cambio. El cacheo empieza a ganar cuando muchos consumidores comparten la derivación en el mismo tick.
flowchart TD subgraph Funcion sin cache F1[signal] --> R1[lector 1 recomputa] F1 --> R2[lector 2 recomputa] F1 --> R3[lector 3 recomputa] end subgraph Memo con cache G1[signal] --> MM[memo un calculo] MM --> L1[lector 1] MM --> L2[lector 2] MM --> L3[lector 3] end style MM fill:#cba6f7,color:#11111b style F1 fill:#89b4fa,color:#11111b style G1 fill:#89b4fa,color:#11111b
La regla mental: el beneficio es, más o menos, el coste del cálculo multiplicado por los lectores menos uno, restado el coste fijo del nodo. Un arr().length con tres lectores no justifica un memo; un arr().filter(pesado).sort() con tres lectores, sí. Un memo lee arr una vez, ordena una vez y sirve el mismo resultado tres veces; la función ordenaría tres veces.
Ponle números para verlo claro. 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 único lector, ambos gastan dos milisegundos y el memo solo añade el coste de mantener su nodo. El cruce es nítido: cuantos más lectores y más caro el cálculo, más rinde el memo; con pocos lectores y cálculo barato, el memo es puro lastre que ni la mejor intención justifica.
const caro = createMemo(() => ordenar(items())); // o: const caro = () => ordenar(items())
// Un lector: memo y función empatan (más el coste del nodo)
createEffect(() => usar(caro()));
// Cinco lectores en el mismo tick: el memo ordena 1 vez; la función, 5
Por eso el número de lectores no es un detalle secundario: es la variable que decide. Cuenta cuántos consumidores comparten la derivación en un mismo tick antes de memoizar; si la respuesta es “uno”, casi siempre te sobra el memo.
Identidad estable: la tercera razón
Hay un motivo para memoizar que no va de velocidad bruta sino de referencia. Un memo devuelve la misma referencia hasta que su contenido cambia; una función devuelve un objeto nuevo en cada llamada. Aguas abajo, un <For>, un children o un efecto que compara por identidad dependen de esa estabilidad para no rehacer trabajo:
// Una función crearía un array nuevo cada vez que el For lo lee,
// forzando reconciliaciones inútiles. El memo estabiliza la referencia:
const visibles = createMemo(() => items().filter((i) => i.activo));
<For each={visibles()}>{(i) => <Fila dato={i} />}</For>
Aquí el memo no ahorra un cálculo caro: ahorra que el <For> reciba una referencia distinta en cada acceso y remonte filas sin necesidad. Identidad estable es, junto con el corte de propagación de la lección 4, una razón de peso aunque el cálculo sea barato.
El mismo argumento vale para children y para efectos que comparan por identidad. Si aguas abajo alguien decide actuar solo cuando la referencia cambia, necesitas que esa referencia sea estable, y solo un memo —o un signal— te la da; una función te entrega un objeto nuevo en cada lectura y sabotea ese === de raíz:
// El efecto reordena el DOM solo cuando la clave de contenido cambia:
const clave = createMemo(() => visibles().map((i) => i.id).join(","));
createEffect(() => { clave(); reordenar(); });
La regla se resume así: si aguas abajo hay un === que decide si trabajar, arriba necesitas un memo. Una función te entrega una referencia nueva cada vez y nunca satisface ese === dos lecturas seguidas; el memo, sí, mientras el contenido no cambie.
El anti-patrón: derivar hacia un signal
La tentación de quien viene de otros modelos es guardar el valor derivado en su propio signal y mantenerlo al día con un efecto:
// ❌ NO hagas esto: estado derivado en un signal
const [doble, setDoble] = createSignal(0);
createEffect(() => setDoble(n() * 2));
Esto duplica la fuente de la verdad. Introduce un tick extra —doble va siempre un paso por detrás de n—, rompe la garantía de consistencia del grafo y abre la puerta a que alguien escriba setDoble a mano y lo desincronice. Todo lo que este efecto hace, un memo lo hace mejor y en un tick:
// ✅ Una sola verdad, derivada y consistente
const doble = createMemo(() => n() * 2);
¿Y si de verdad necesitas un derivado que también se pueda escribir desde fuera —un campo calculado por defecto que el usuario puede sobrescribir—? Ese caso existe y tiene solución propia, pero no es el createSignal más createEffect ingenuo: es un patrón deliberado que conserva una única fuente de la verdad y expone la escritura de forma controlada. La sincronización por efecto no es ese patrón; es su imitación rota.
Si te encuentras escribiendo un createEffect cuya única misión es llamar a un setter con un valor calculado de otros signals, párate: casi siempre eso es un memo disfrazado, y disfrazado mal. Los efectos son para salidas —tocar el DOM, la red, un log, algo fuera del grafo—, no para alimentar más estado interno. Derivar dentro del grafo es trabajo de un memo, que lo hace en un solo tick y sin exponer un setter que otro pueda corromper. La excepción son los casos donde de verdad necesitas un derivado escribible por fuera, y para eso existen primitivos específicos; pero la sincronización ingenua con efecto no es uno de ellos.
El error de principiante es memoizar todo por si acaso; el de intermedio, temer al memo y recalcular cosas caras N veces. El profesional razona con un modelo de coste explícito. Por defecto, una derivación es una función simple: es la opción más barata, más legible y ya reactiva. Subes a createMemo cuando concurre al menos una de tres razones concretas: el cálculo es caro y lo comparten varios lectores en el mismo tick; necesitas cortar la propagación cuando el resultado no cambia; o necesitas una referencia estable para un For, un children o un efecto aguas abajo. Y nunca deriva hacia un signal propio: eso no es derivar, es duplicar la verdad y firmar un contrato de desincronización. Si sabes por qué pones cada memo, tu grafo será tan pequeño como pueda y tan rápido como deba.
- Escribe una derivación cara:
const ordenado = () => [...items()].sort(caro). Léela en tres sitios y cuenta las ejecuciones. - Conviértela en
createMemoy vuelve a contar: deberías ver una sola por cambio deitems. - Ahora hazla trivial:
const total = () => items().length. Pregúntate si un memo aportaría algo con uno o dos lectores. Justifícalo. - Toma un
createSignal+createEffectque sincronice un derivado y reescríbelo como un único memo. Explica qué desincronización eliminaste. - Pasa la misma derivación a un
<For>primero como función y luego como memo. Observa en las devtools cuáles filas se remontan y cuáles se conservan.