La igualdad como frontera de propagación
La función de comparación decide qué cuenta como cambio, y por tanto dónde se detiene la propagación. Identidad, estructural o a medida: qué cuesta cada opción y cuándo cada una es la correcta.
De todos los parámetros que se pueden ajustar en un motor reactivo, la función de igualdad es el que más gobierna el trabajo total del sistema y el que menos gente toca. Decide, para cada nodo, la frontera entre un cambio que se propaga y uno que no; es decir, decide dónde se para la cascada. Un cambio de igualdad en el nodo correcto puede recortar un orden de magnitud de trabajo, y en el nodo equivocado puede producir un bug de datos rancios.
- Comparar identidad, igualdad estructural e igualdad a medida por coste y efecto.
- Localizar los nodos donde cambiar la igualdad tiene mayor impacto.
- Reconocer el riesgo de una igualdad demasiado permisiva.
- Usar la igualdad como herramienta de control del grafo, no como detalle.
Las tres opciones
Identidad, con Object.is. El valor por defecto en todos los motores. Coste constante. Detecta cualquier objeto nuevo como un cambio, aunque su contenido sea idéntico.
const config = senal({ tema: 'oscuro' });
config.set({ tema: 'oscuro' }); // objeto nuevo, misma forma: SI propaga
Esa última línea es el comportamiento que sorprende a todo el mundo y que es correcto según el contrato. Con identidad, crear un objeto nuevo es cambiar, punto.
Igualdad estructural. Compara el contenido recursivamente. Coste proporcional al tamaño del valor. Evita propagar cuando el contenido es equivalente.
const config = senal({ tema: 'oscuro' }, { iguales: (a, b) => a.tema === b.tema });
config.set({ tema: 'oscuro' }); // NO propaga
A medida. Cualquier predicado que tenga sentido en el dominio. Es la opción más potente y la menos usada.
// Solo propagar si el precio cambia mas de un centimo
const precio = senal(0, { iguales: (a, b) => Math.abs(a - b) < 0.01 });
// Comparar solo por identificador, ignorando el resto del objeto
const usuario = senal(null, { iguales: (a, b) => a?.id === b?.id });
// Nunca considerar iguales: propagar siempre, util para eventos
const pulso = senal(0, { iguales: () => false });
Dónde importa más
El impacto de cambiar la igualdad no es uniforme: depende de cuántos descendientes tenga el nodo. Un memo con dos observadores ahorra dos recálculos; uno con doscientos ahorra doscientos, y todos sus subárboles.
De ahí la regla de colocación: la igualdad estructural rinde donde la ramificación es alta y el valor es pequeño.
// Nodo de alta ramificacion, valor minusculo: candidato perfecto
const hayErrores = memo(() => Object.keys(errores()).length > 0);
// leido por veinte componentes; el objeto errores cambia en cada tecla,
// pero el booleano casi nunca. Object.is ya basta aqui: los booleanos
// se comparan por identidad y funciona.
// Nodo de alta ramificacion con valor de objeto: aqui si hace falta
const filtrosActivos = memo(
() => ({ texto: consulta(), soloActivos: bandera() }),
{ iguales: (a, b) => a.texto === b.texto && a.soloActivos === b.soloActivos }
);
// sin la igualdad a medida, cada evaluacion produce un objeto nuevo
// y propaga siempre, aunque el contenido no haya cambiado
El segundo caso es importantísimo y frecuentísimo: un memo que devuelve un objeto literal nunca corta la propagación con la igualdad por defecto, porque cada ejecución produce una referencia nueva. Es una de las causas más comunes de que un grafo aparentemente bien memoizado no ahorre nada.
flowchart TB F[fuente que cambia mucho] --> M[memo que devuelve un objeto literal] M -->|siempre propaga con Object.is| C1[consumidor 1] M --> C2[consumidor 2] M --> C3[consumidor 3] M --> C4[consumidor N] style F fill:#89b4fa,color:#11111b style M fill:#f38ba8,color:#11111b style C1 fill:#f9e2af,color:#11111b style C2 fill:#f9e2af,color:#11111b style C3 fill:#f9e2af,color:#11111b style C4 fill:#f9e2af,color:#11111b
El coste de comparar
La igualdad estructural no es gratis, y el cálculo de si compensa es sencillo de plantear.
Compensa cuando el coste de comparar es menor que el coste esperado de propagar. Y el coste esperado de propagar es el trabajo del subgrafo entero multiplicado por la probabilidad de que el valor haya cambiado de verdad.
comparar < trabajo_del_subgrafo * probabilidad_de_cambio_real
De ahí salen las dos situaciones donde la igualdad estructural es una mala idea. Si el valor casi siempre cambia, la probabilidad es cercana a uno y solo estás añadiendo el coste de la comparación a un trabajo que se va a hacer igualmente. Y si el valor es grande y el subgrafo pequeño, comparar un array de diez mil elementos para ahorrar un recálculo trivial es un mal negocio.
Una comparación estructural profunda sobre estructuras grandes se ejecuta en cada escritura, mientras que el trabajo que evita solo ocurre cuando hay cambio. Si el valor cambia el diez por ciento de las veces, estás pagando diez comparaciones completas por cada propagación ahorrada. En estructuras de miles de elementos eso es casi siempre una pérdida neta. Las alternativas son comparar solo una firma —una longitud, un identificador, una versión— o partir el nodo en varios más pequeños.
Riesgos y APIs reales
El riesgo de una igualdad demasiado permisiva
Una función de igualdad que declara iguales cosas que no lo son produce un fallo silencioso y desagradable: el valor se actualiza en la fuente pero no se propaga, así que los consumidores siguen viendo lo viejo, y no hay ningún error.
// PELIGROSO: compara solo por id, ignorando el resto
const usuario = senal(null, { iguales: (a, b) => a?.id === b?.id });
usuario.set({ id: 1, nombre: 'Ana' });
usuario.set({ id: 1, nombre: 'Ana Maria' }); // NO propaga: mismo id
// La interfaz sigue mostrando 'Ana'. Sin error.
La regla de seguridad es directa: la función de igualdad tiene que considerar todos los campos que algún consumidor pueda leer. Si no puedes garantizarlo —porque los consumidores están repartidos por la aplicación y pueden cambiar—, no toques la igualdad por defecto. Una comparación demasiado estricta cuesta rendimiento; una demasiado permisiva cuesta corrección, y ese es un intercambio muy malo.
Cuando de verdad quieras comparar solo por identificador, la forma segura es partir el nodo: una señal para el identificador y otra para el resto, cada una con su propia igualdad, y consumidores distintos para cada una.
Aquí está la razón por la que este parámetro merece un lugar destacado y no una nota al pie. En todo el resto del motor, el algoritmo es genérico: no sabe nada de tu aplicación y no puede saberlo. La función de igualdad es el único punto de extensión donde tú aportas semántica del dominio a la maquinaria de propagación. Cuando dices que dos precios que difieren en menos de un céntimo son el mismo precio, estás enseñándole al motor algo que jamás podría deducir. Cuando dices que dos posiciones de puntero en el mismo píxel son la misma posición, le estás dando permiso para descartar el noventa por ciento de los eventos. Ese es un poder considerable y por eso viene con una responsabilidad proporcional: el motor confiará en tu función, y si mientes servirá datos rancios sin quejarse. Hay una analogía exacta y útil: es la misma relación que la de un compilador con un unsafe, o la de un recolector de basura con un puntero débil. El sistema te ofrece un hueco para meter conocimiento que él no tiene, se fía de ti, y a cambio deja de protegerte en ese punto. Trata cada función de igualdad a medida como lo que es —una aserción sobre tu dominio— y escríbela junto a un comentario que diga qué campos ignora y por qué es seguro ignorarlos.
Cómo lo expone cada motor
Todos permiten personalizarla, con nombres parecidos. Solid acepta una opción equals en createSignal y en createMemo, y admite false para desactivar la comparación por completo. Vue tiene el customRef para control total del rastreo y del disparo, además de shallowRef para comparar solo la referencia de nivel superior. Angular acepta una opción equal en signal() y en computed(). Preact Signals compara por identidad y no expone un punto de extensión directo en su API pública. La propuesta de TC39 acepta una opción equals tanto en Signal.State como en Signal.Computed.
- Vuelca el grafo de una pantalla real y ordena los nodos por número de descendientes.
- Para los cinco primeros, comprueba si su valor es un objeto literal creado en cada ejecución.
- Añade una igualdad estructural en el que más descendientes tenga y mide los cuerpos ejecutados antes y después.
- Escribe una igualdad deliberadamente permisiva en otro nodo y comprueba que el fallo resultante es silencioso.