wandres.dev
SIGNALS AVANZADOS · equals y opciones

equals: false y comparadores a medida

El tercer argumento de createSignal decide qué significa cambiar. Por defecto es una igualdad estricta, pero puedes forzar notificación en cada escritura con equals: false o imponer tu propia relación de equivalencia con un comparador que iguale por contenido, por id o por versión.

⏱ 15 min

El valor de un signal nunca es solo un valor: es un valor módulo una relación de equivalencia. Cada escritura plantea una pregunta silenciosa —¿esto difiere lo suficiente como para despertar al grafo?— y quien la responde es equals. Dominarlo es dejar de sufrir las notificaciones que Solid infiere y empezar a declarar, con precisión quirúrgica, qué escrituras son diferencias reales y cuáles ruido que el sistema debe absorber sin mover un solo observador.

🎯 Al terminar esta lección sabrás
  • Leer la firma completa de createSignal y ubicar la opción equals.
  • Convertir un signal en un pulso con equals: false que notifica siempre.
  • Escribir un comparador a medida que iguale por contenido, id o versión.
  • Razonar el coste y la corrección de un comparador antes de imponerlo.

La firma: equals define qué es cambiar

createSignal acepta un tercer canal de configuración donde vive la decisión más infravalorada del primitivo. Su forma completa es esta:

const [get, set] = createSignal<T>(inicial, {
  equals: (prev, next) => prev === next, // el valor por defecto
});

equals es una función (prev, next) => boolean con una convención que conviene grabar del revés: devuelve true cuando considera que los valores son iguales, e igual significa no notifiques. La comparación por defecto es ===, la igualdad estricta por referencia. Para números, cadenas y booleanos es exactamente lo que quieres: reescribir 5 sobre 5 es un no-op silencioso y el grafo entero se ahorra el trabajo.

La lectura profunda es que un signal no expone el flujo bruto de escrituras, sino ese flujo cocientado por la relación que define equals. Dos escrituras que la relación juzga equivalentes colapsan en una sola desde el punto de vista de los observadores. Cambiar equals es redefinir esa relación de equivalencia y, con ella, la identidad misma del estado que el grafo percibe.

Conviene una precisión de nivel avanzado: el defecto es ===, no Object.is. La diferencia importa en dos valores traicioneros. NaN === NaN es false, así que un signal que guarda NaN y recibe otro NaN notifica, porque la igualdad estricta los considera distintos. Y -0 === 0 es true, de modo que escribir -0 sobre 0 se descarta como no-op. Rara vez muerden, pero cuando tu dominio incluye cálculos numéricos con estos casos límite, saber qué relación exacta usa el defecto te ahorra un depurado desconcertante.

flowchart LR
W[setSignal nuevo valor] --> Q[equals compara prev y next]
Q -->|iguales, true| X[descarta y no notifica]
Q -->|distintos, false| N[notifica a observadores]
style W fill:#89b4fa,color:#11111b
style Q fill:#f9e2af,color:#11111b
style N fill:#a6e3a1,color:#11111b
style X fill:#f38ba8,color:#11111b

equals: false, el signal como pulso

El extremo opuesto a comparar es no comparar. Con equals: false, toda escritura notifica, aunque el valor sea idéntico al anterior:

// Notifica en cada escritura, coincida o no el valor:
const [ping, setPing] = createSignal(undefined, { equals: false });

createEffect(() => { ping(); disparar(); });
setPing(undefined); // el efecto corre igual, una y otra vez

Esto degrada deliberadamente el signal a un disparador: cada escritura es un evento, no un cambio de estado. Sus usos legítimos son contados —reejecutar un efecto tras una acción idempotente, envolver un buffer mutable que reescribes en su sitio, contar pulsos de un latido— y todos comparten una firma: el acto de escribir importa más que el valor escrito.

El caso del buffer mutable ilustra por qué existe la válvula. Si guardas un Float32Array que reescribes en su sitio por rendimiento —clonarlo en cada fotograma sería absurdo— la referencia nunca cambia y el === por defecto nunca notificaría. Aquí equals: false es legítimo: reconoces que el contrato de inmutabilidad no aplica y le pides al grafo que confíe en el acto de escritura como señal de cambio.

// Un buffer que reescribes en su sitio: la referencia no cambia nunca
const [muestras, setMuestras] = createSignal(buffer, { equals: false });
llenarEnSitio(buffer); // muta el mismo Float32Array
setMuestras(buffer);   // con equals: false, notifica pese a la misma referencia
⚠️
equals: false reintroduce el ruido que equals existe para cortar

Antes de alcanzar equals: false, pregúntate si no estás modelando un evento con la herramienta equivocada. Un signal que notifica siempre arrastra a todos sus observadores en cada escritura, y ese aluvión es justo lo que la comparación por defecto te regala gratis. Nueve de cada diez veces, lo que crees necesitar como equals: false es un evento —y para eventos hay primitivos y patrones mejores que un signal fingiendo cambiar—. Resérvalo para cuando de verdad quieras tratar cada escritura como un pulso.

Comparadores a medida: igualar por contenido

Entre el === estricto y el equals: false promiscuo vive el territorio más rico: enseñarle al grafo qué significa cambiar para tu dato concreto. Un signal que guarda objetos con === notifica en cuanto cambia la referencia, aunque el contenido sea idéntico; un comparador a medida corta ese ruido igualando por lo que de verdad importa.

type Item = { id: string; nombre: string; version: number };

// Notifica solo si cambia la version; ignora reasignaciones equivalentes:
const [item, setItem] = createSignal<Item>(inicial, {
  equals: (prev, next) => prev.version === next.version,
});

// Igualdad superficial por claves:
const superficial = <O extends Record<string, unknown>>(a: O, b: O) => {
  const ka = Object.keys(a);
  return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
};
const [perfil, setPerfil] = createSignal(dato, { equals: superficial });

Comparar por id, por version o por un hash es casi siempre superior a una comparación estructural profunda: te da la potencia de igualar por contenido pagando un coste constante en lugar de recorrer el objeto entero. Si tu fuente emite referencias nuevas con contenido repetido —una respuesta de red que revalidas, un map que reconstruye la lista— un comparador barato absorbe esas coincidencias sin despertar a nadie.

ℹ️
El mismo equals gobierna a createMemo

Esta opción no es exclusiva del signal: createMemo acepta el mismo equals como tercer argumento, con idéntica semántica. En un memo, equals decide si el resultado recién recomputado se propaga a los observadores —es una compuerta de salida—, mientras que en un signal decide si la escritura entrante se acepta como cambio. En ambos, la relación de equivalencia que elijas define qué diferencias cruzan el nodo. Dominar equals en el signal es dominarlo en todo el vocabulario reactivo de Solid.

🟰

=== por defecto

Compara por referencia estricta. Ideal para primitivas; con objetos te empuja a la inmutabilidad. La opción correcta para casi todo.

🚫

equals: false

Disuelve la relación: cada escritura notifica. El signal se vuelve un disparador de eventos, no un portador de estado.

🎯

Comparador a medida

Una función (prev, next) => boolean. Devuelve true para no notificar. Iguala por id, version, hash o contenido superficial.

El comparador corre en cada escritura

Un comparador no es gratis: se ejecuta en cada intento de escritura, se descarte o no el valor. Un equals que normaliza estructuras o recorre arrays grandes suma su coste al de cada set, y puede acabar costando más de lo que ahorra aguas abajo. La regla es estimar antes de imponer: ¿cuánto cuesta comparar frente a cuánto trabajo evitas si la comparación acierta?

Pero el peligro serio no es el rendimiento, es la corrección. Un comparador demasiado laxo —que devuelve true para valores que en realidad difieren— hace que Solid descarte actualizaciones legítimas: la UI se queda congelada mostrando un estado viejo y no hay error que lo delate. Igualar por id cuando el resto del objeto cambia es el caso clásico: cambias el nombre, mantienes el id, y la vista nunca se entera.

// PELIGRO: iguala por id, pero el nombre puede cambiar sin notificar
const [u, setU] = createSignal(user, { equals: (a, b) => a.id === b.id });
setU({ ...user, nombre: "otro" }); // mismo id -> la vista NO se actualiza

De ahí una asimetría de riesgo que conviene grabar: los dos errores no cuestan lo mismo. Un comparador demasiado estricto —que distingue de más— solo desperdicia trabajo: notifica cuando no hacía falta, y lo peor que pasa es un recálculo de sobra. Un comparador demasiado laxo —que iguala de más— corrompe datos: silencia cambios reales y muestra estado obsoleto sin dejar rastro. Ante la duda, peca siempre por el lado estricto, porque el coste de una notificación de más es visible y acotado, mientras que el de una de menos es invisible y se paga en horas de depurado sobre un síntoma que ni siquiera parece reactivo.

⚠️
Un comparador laxo pierde actualizaciones en silencio

La comparación por defecto peca de conservadora: ante la duda, notifica. Un comparador a medida invierte esa carga y, si te equivocas por exceso de laxitud, el sistema deja de propagar cambios reales sin avisar. Comprueba siempre que tu relación de igualdad distingue todo lo que un observador necesita ver cambiar. Si dudas entre incluir dos campos, incluye ambos: una notificación de más es un desperdicio, una de menos es un bug de datos obsoletos que cuesta horas encontrar.

Un signal es un valor cociente por una relación de equivalencia

La ingenuidad ve un signal como una caja con un valor dentro; la madurez lo ve como un valor módulo equals. Esa función no es un detalle de optimización colgado al margen: es la definición operativa de qué significa cambiar para ese estado, y por tanto qué escrituras el grafo considerará diferencias dignas de propagarse y cuáles colapsarán en silencio. Con === por defecto, dos referencias distintas son siempre dos valores distintos —perfecto para primitivas, exigente con objetos, porque te empuja hacia la inmutabilidad—. Con equals: false disuelves la relación por completo: no hay dos escrituras equivalentes, todas notifican, y el signal deja de modelar estado para modelar eventos. Y con un comparador a medida esculpes tú la relación: declaras que dos objetos con el mismo id, o la misma version, o el mismo hash, son el mismo valor a ojos del grafo, por más que difieran sus referencias. Elegir esa relación es una decisión de diseño con dos filos: por el del rendimiento, decides cuánto trabajo hace tu aplicación en cada escritura; por el de la corrección, decides qué cambios son visibles y cuáles se pierden. Quien acepta el === por defecto casi siempre acierta y no piensa en ello. Quien lo domina afina la propagación al recálculo exacto, pero carga con la responsabilidad de que su relación de equivalencia no borre ninguna diferencia que alguien, aguas abajo, necesitaba ver.

⚔️ Redefine qué significa cambiar
  1. Crea un signal con equals: false, escríbele el mismo valor tres veces y confirma con un efecto que notifica en las tres.
  2. Envuelve un objeto en un signal por defecto, mútalo y reestablece la misma referencia; verifica que no notifica. Arréglalo esparciendo con { ...obj }.
  3. Escribe un comparador que iguale por id y demuestra, con dos escrituras de distinto id, que notifica; luego con el mismo id y distinto nombre, que no.
  4. Convierte ese fallo en un bug visible: renderiza el nombre y comprueba que la vista se queda obsoleta. Corrige el comparador para incluir el nombre.
  5. Mide el coste: pon un console.count dentro de un comparador estructural y cuenta cuántas veces corre al escribir en ráfaga. Cámbialo por comparación de version y compara.