wandres.dev
CREATEMEMO · valores derivados

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.

⏱ 13 min

Un signal cambia mil veces por segundo, pero el valor que de verdad te importa —un booleano, un tramo, un objeto normalizado— apenas cambia. equals es la válvula que aprovecha esa diferencia: tras recomputar, el memo compara el nuevo resultado con el anterior y solo notifica a sus observadores si cambió. Bien usado, un memo se convierte en una membrana que absorbe ruido y deja pasar solo señal.

🎯 Al terminar esta lección sabrás
  • Entender qué compara equals y cuándo se ejecuta.
  • Usar el equals por defecto para cortar propagación.
  • Escribir un equals personalizado que iguale por contenido.
  • Distinguir recomputación del memo de notificación a observadores.

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.

flowchart LR
S[signal contador] -->|cada tick| M[memo esPar]
M -->|solo si cambia| E[effect y vista]
M -.->|si es igual, corta| X[nada]
style S fill:#89b4fa,color:#11111b
style M fill:#cba6f7,color:#11111b
style E fill:#a6e3a1,color:#11111b
style X fill:#f38ba8,color:#11111b

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í.

⚠️
Un equals caro puede costar más de lo que ahorra

El comparador corre en cada recomputación del memo. Si comparar por contenido recorre estructuras grandes o normaliza objetos, ese trabajo se suma al del cuerpo, no lo sustituye. Antes de escribir un equals estructural, estima: ¿cuánto cuesta comparar frente a cuánto trabajo evitas aguas abajo? Si el downstream es barato o el comparador es pesado, un equals a medida es contraproducente y estás pagando por un corte que no rentabilizas. Compara por hash o versión cuando puedas, y no impongas comparación estructural donde la identidad por defecto ya acierta.

El memo es una membrana con dos caras

Piensa en cada memo como una membrana con una compuerta de entrada y otra de salida. La de entrada es la suscripción: el cuerpo del memo corre cuando cambian sus fuentes —eso no lo controla equals—. La de salida es equals: decide si el resultado recién calculado merece despertar a los observadores. Separar mentalmente estas dos caras te da un vocabulario preciso para diseñar grafos eficientes. ¿Recomputas de más? El problema está en la cara de entrada: divide en memos intermedios que filtren antes. ¿Propagas de más y disparas efectos caros sin necesidad? El problema está en la cara de salida: ajusta equals para igualar por contenido, o reduce a un valor discreto que cambie poco. La identidad por defecto es correcta para primitivas y referencias estables; el equals a medida es para estructuras que renaces en cada cálculo pero que a menudo son idénticas; y equals: false es el martillo que casi nunca necesitas. Un ingeniero de Solid maduro coloca sus cortes de propagación con la misma intención con la que un arquitecto coloca juntas de dilatación: donde el movimiento debe pararse para que el resto no sufra.

⚔️ Coloca tus cortes de propagación
  1. Crea un signal que incrementes en un intervalo y un memo pasoDe10 que devuelva Math.floor(n() / 10). Comprueba que la vista solo se actualiza cada diez incrementos.
  2. Haz un memo que devuelva items().filter(...) y un efecto caro que lo lea. Observa que se dispara aunque el filtro dé la misma lista.
  3. Añade un equals por contenido y confirma que el efecto deja de dispararse cuando los ids no cambian.
  4. Mete un console.log dentro del cuerpo del memo y verifica que sigue corriendo aunque equals corte la salida. Explica por qué.
  5. Aplica el patrón de dos memos a un scroll: un memo barato que discretiza y uno caro suscrito a él. Comprueba que el caro solo corre al cambiar de tramo.