equals en memos: cortar la propagación
Cada memo tiene un comparador equals que decide si su nuevo valor notifica a los observadores. Por defecto compara por identidad; personalizado, iguala por contenido. Es el mecanismo con el que un memo detiene la propagación cuando el resultado no cambia.
El comparador que corta la propagación
Todo memo lleva un comparador. Por defecto es la identidad, equivalente a (prev, next) => prev === next. Cuando el memo recomputa, si el nuevo valor es igual al anterior según equals, Solid no despierta a nadie aguas abajo. La propagación se detiene ahí.
const [contador, setContador] = createSignal(0);// Cambia con cada tick, pero par/impar solo cambia a veces:const esPar = createMemo(() => contador() % 2 === 0);createEffect(() => console.log("par?", esPar()));// El effect solo corre cuando esPar CAMBIA de valor,// no en cada incremento del contador.Esta es la optimización clásica: un memo que reduce una fuente ruidosa a un valor discreto —contador() > 100, Math.min(x(), 10), un estado "cargando" | "listo"— filtra la mayoría de los cambios antes de que lleguen a la vista o a efectos caros. El memo recomputa contador() % 2 en cada incremento, sí, pero la propagación solo sigue cuando el booleano se invierte.
Quien venga de la programación reactiva por streams reconocerá el patrón: equals es el distinctUntilChanged de Solid. Un memo que proyecta una señal continua a un conjunto de estados discretos emite solo en las transiciones, nunca en cada muestra intermedia. Esa es la esencia del corte de propagación.
Cámbialo mentalmente a contador() > 500: de mil incrementos consecutivos, ese booleano transiciona una sola vez, al cruzar el umbral. Novecientas noventa y nueve recomputaciones del cuerpo del memo, pero una única notificación aguas abajo. Ahí ves el corte en toda su fuerza: el cuerpo trabaja mucho, la propagación casi nada.
Este es, con diferencia, uno de los usos más rentables de un memo: no por su cacheo, sino por su capacidad de no propagar. Un memo que discretiza una fuente ruidosa paga su nodo con creces protegiendo a toda la UI aguas abajo de un aluvión de actualizaciones que no cambiarían nada visible.
equals personalizado: igualar por contenido
El comparador por defecto usa identidad, así que un memo que devuelve un objeto o array nuevo propaga siempre, aunque el contenido sea idéntico —cada filter o map crea una referencia fresca—. Cuando eso desata trabajo caro aguas abajo, pásale un equals que compare por contenido:
const seleccion = createMemo( () => items().filter((i) => i.activo), undefined, { equals: (prev, next) => prev.length === next.length && prev.every((p, k) => p.id === next[k].id), },);Ahora, si el filtro produce la misma lista de ids, el memo no notifica, aunque el array sea otra referencia. La comparación estructural es tu herramienta cuando la fuente cambia con frecuencia pero el proyectado —una selección, un resumen, unas claves ordenadas— coincide a menudo con el anterior. Para estructuras grandes puedes comparar por un hash o una versión en vez de recorrerlas enteras.
Un caso frecuente es un memo que alimenta un componente caro de renderizar, como un gráfico. Sin equals, cada recálculo de la fuente lo redibuja aunque los puntos sean idénticos; con equals por contenido, el gráfico solo se rehace cuando los datos cambian de verdad. Y si trabajas con createStore, recuerda que reconcile juega en otra liga: en lugar de cortar la propagación aguas arriba, aplica el cambio de forma granular aguas abajo para que solo se actualice la parte del árbol que difiere.
Para objetos planos, un equals de igualdad superficial cubre la mayoría de los casos sin recorrer estructuras anidadas:
const superficial = (a: Record<string, unknown>, b: Record<string, unknown>) => { const ka = Object.keys(a); return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);};const perfil = createMemo(() => proyectar(usuario()), undefined, { equals: superficial,});equals: false y los memos-señal
El caso opuesto al comparador estricto es desactivarlo. Con equals: false, el memo notifica en cada recomputación aunque el valor sea idéntico al anterior:
// Un memo que siempre emite, incluso si el valor no cambia:const latido = createMemo(() => fuente(), undefined, { equals: false });Esto convierte al memo en un disparador: cada recálculo se trata como un evento —reejecutar un efecto aunque el dato coincida, forzar una animación, contar pulsos—:
// Cada nueva version fuerza una recarga, aunque el token calculado coincida:const gatillo = createMemo(() => version(), undefined, { equals: false });createEffect(() => { gatillo(); recargar(); });Pero es raro y hay que usarlo con conciencia: reintroduces exactamente el ruido que equals existe para cortar, y un efecto aguas abajo se disparará más de lo que quizá esperas. Nueve de cada diez veces, si crees necesitar equals: false, lo que buscas es modelar un evento con otro primitivo, no un memo que finge ser evento.
Lo que equals no hace
Aquí el matiz que separa a quien entiende el grafo de quien lo intuye: equals no evita que el memo recompute su propia función. Cuando una fuente cambia, el cuerpo del memo siempre se ejecuta; equals solo decide, con el resultado ya en mano, si ese resultado se empuja a los observadores. Es una compuerta de salida, no de entrada.
// El cuerpo corre en cada cambio de fuente;// equals solo decide si el nuevo valor sale del nodo.const m = createMemo(() => calculoCaro(x()), undefined, { equals: (a, b) => a.hash === b.hash,});La consecuencia práctica: equals reduce el trabajo aguas abajo, no el del propio memo. Si lo que quieres es evitar recomputar el cuerpo, el corte tiene que estar antes, en un memo intermedio más barato que filtre la fuente y a cuyo cambio se suscriba este. Es el patrón de dos memos: uno barato que discretiza, otro caro que solo despierta cuando el discreto cambia.
const [scroll, setScroll] = createSignal(0);// Barato: reduce miles de posiciones a un tramo discreto.const seccion = createMemo(() => Math.floor(scroll() / 500));// Caro: se suscribe a 'seccion', no a 'scroll';// su cuerpo corre al cambiar de tramo, no en cada pixel.const resumen = createMemo(() => calcularResumenPesado(seccion()));Aquí el equals por defecto de seccion hace todo el trabajo: aunque scroll cambie cien veces dentro del mismo tramo, seccion devuelve el mismo entero, no notifica, y resumen ni se entera. Has colocado el corte antes del cálculo caro, que es donde de verdad ahorra.
Interioriza la asimetría de una vez: la entrada de un memo es su suscripción, y no la controlas con equals; la salida es la notificación, y esa sí. Confundirlas lleva al error clásico de poner un equals esperando que el cuerpo deje de ejecutarse —y no lo hace nunca—. Si el cuerpo es el caro, el corte va antes; si el caro está aguas abajo, el corte va aquí.