wandres.dev
RENDIMIENTO DEL ESTADO · el coste de reaccionar

El coste de comparar: referencia, superficial y profunda

Todo sistema reactivo necesita decidir si un valor cambió, y esa decisión se toma con una de tres comparaciones cuyos costes difieren en órdenes de magnitud: identidad en tiempo constante, superficial en el número de claves del nivel, profunda en el número total de nodos. Elegir mal no solo consume ciclos, sino que puede volver la comparación más cara que el trabajo que pretendía evitar. Esta lección establece el coste real de cada estrategia, el criterio para elegir entre ellas, y por qué la igualdad barata es la contrapartida exacta de la disciplina de inmutabilidad.

⏱ 17 min

Detrás de cada optimización de un sistema reactivo hay una comparación. El memo compara propiedades, el selector compara su resultado, el efecto compara dependencias, el nodo derivado compara su valor anterior con el nuevo para decidir si sigue propagando. Esa comparación es el guardián que evita trabajo innecesario, pero también es trabajo en sí misma, y hay un punto en el que el guardián cuesta más que lo que vigila. Las tres estrategias disponibles no son intercambiables: la identidad es constante y frágil, la superficial es lineal en un nivel y suficiente casi siempre, la profunda es lineal en toda la estructura y casi nunca merece la pena. Saber cuál usar, y qué se paga por cada una, es la diferencia entre memoizar y fingir que memoizas.

🎯 Al terminar esta lección sabrás
  • Enunciar el coste asintótico de la igualdad por identidad, superficial y profunda.
  • Elegir la comparación adecuada según la forma del valor y la frecuencia de la escritura.
  • Detectar el antipatrón en el que comparar cuesta más que recalcular.
  • Relacionar la inmutabilidad con el privilegio de comparar en tiempo constante.

Las tres estrategias y su factura

La igualdad por identidad pregunta si dos referencias apuntan al mismo objeto. Es una comparación de punteros: coste constante, sin recorrer nada. Es la que usan por defecto React, Zustand, Solid y prácticamente cualquier sistema serio, y es la razón por la que la inmutabilidad importa tanto, porque bajo ese contrato una referencia nueva significa siempre un valor distinto y una referencia igual significa siempre un valor idéntico.

// identidad: coste constante, compara punteros
Object.is(a, b)

// superficial: coste lineal en las claves de UN nivel
function superficial(a: any, b: any): boolean {
  if (Object.is(a, b)) return true
  const ka = Object.keys(a), kb = Object.keys(b)
  if (ka.length !== kb.length) return false
  return ka.every(k => Object.is(a[k], b[k]))
}

// profunda: coste lineal en TODOS los nodos de la estructura
function profunda(a: any, b: any): boolean {
  if (Object.is(a, b)) return true
  if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false
  const ka = Object.keys(a), kb = Object.keys(b)
  if (ka.length !== kb.length) return false
  return ka.every(k => profunda(a[k], b[k]))
}

La superficial recorre las claves de un solo nivel y compara sus valores por identidad. Su coste es proporcional al número de claves de ese nivel, un número que en un estado bien estructurado se cuenta con los dedos. Es la comparación adecuada cuando el valor comparado es un contenedor recién creado cuyos contenidos sí son estables: el caso típico de un selector que devuelve un objeto literal con tres campos extraídos del store.

Un detalle de la superficial que suele pasar inadvertido es que su corrección depende de una premisa sobre el nivel inferior, no sobre el que compara. Compara referencias de los valores, así que declara iguales dos contenedores cuyos hijos son el mismo objeto, y eso solo equivale a igualdad real si nadie muta esos hijos en su sitio. Dicho de otro modo, la superficial no es una comparación más tolerante que la identidad: es la identidad aplicada un nivel más abajo, con exactamente la misma exigencia de disciplina.

La profunda recorre la estructura entera. Su coste es proporcional al número total de nodos, y crece con el tamaño del estado, no con el del cambio. Además es peligrosa por razones que no son de rendimiento: los ciclos la cuelgan si no se protege, las funciones y los símbolos no tienen una noción obvia de igualdad, y las fechas o los mapas requieren tratamiento especial que casi ninguna implementación casera contempla.

ℹ️
Igualdad estructural no es igualdad semántica

Ninguna de las tres estrategias responde a la pregunta que de verdad importa, que es si el usuario notaría la diferencia. Dos objetos con las mismas claves y valores pueden representar cosas distintas si uno de ellos es una instancia de clase con identidad propia, y dos objetos estructuralmente distintos pueden ser irrelevantemente distintos si el campo que cambió no se pinta en ninguna parte. Por eso los sistemas maduros permiten pasar un comparador propio: no para ser más listos que la igualdad, sino para expresar qué parte del valor es semánticamente observable.

Cuando el guardián cuesta más que la puerta

El antipatrón central de este nivel es memoizar con una comparación cuyo coste supera al del trabajo evitado. Ocurre siempre por la misma vía: alguien observa un render sobrante, alcanza la herramienta más contundente disponible, y la aplica sin contar.

// ANTIPATRON: comparacion profunda de un estado grande en cada render
const memoizado = useMemo(() => derivar(estado), [JSON.stringify(estado)])
// serializa toda la estructura en cada render para decidir si recalcular
// coste de la guardia: proporcional al estado entero
// coste de derivar: a menudo mucho menor

Aquí la serialización recorre todos los nodos, construye una cadena y la compara carácter a carácter, y todo eso ocurre en cada render para decidir si evitar una función que quizá recorre diez elementos. El resultado es una aplicación que va más lenta después de optimizarla, con la agravante de que el perfil ahora acusa a una función de librería y no al código que la invocó mal.

flowchart TD
Q0[necesito saber si cambio] --> Q1[el valor se produce con actualizacion inmutable]
Q1 -->|si| ID[igualdad por identidad y coste constante]
Q1 -->|no| Q2[es un contenedor nuevo con contenidos estables]
Q2 -->|si| SUP[igualdad superficial de un nivel]
Q2 -->|no| Q3[el valor viene de fuera y no controlo su identidad]
Q3 -->|si| CMP[comparador propio sobre los campos observables]
Q3 -->|no| REC[no compares y recalcula porque sale mas barato]

La regla que ordena el diagrama es sencilla de enunciar y difícil de recordar bajo presión: la comparación debe ser estrictamente más barata que el trabajo que evita, y esa relación debe cumplirse en el caso frecuente, no en el peor. Un selector que devuelve un objeto nuevo en cada llamada y se compara por identidad no memoiza nada, solo añade una comparación fallida a cada actualización. Un selector que devuelve un valor primitivo estable y se compara con Object.is es la forma más barata de suscripción que existe.

// MAL: crea un objeto nuevo cada vez, la identidad nunca coincide
const datos = useStore(s => ({ nombre: s.nombre, edad: s.edad }))

// BIEN: primitivos estables, identidad suficiente y coste constante
const nombre = useStore(s => s.nombre)
const edad = useStore(s => s.edad)

// TAMBIEN BIEN: un objeto por comodidad, pero con igualdad superficial
const datos2 = useStore(s => ({ nombre: s.nombre, edad: s.edad }), superficial)

Hay un caso frontera que conviene nombrar porque genera discusiones estériles: comparar es a veces más caro que recalcular, y entonces la respuesta correcta es no comparar. Una función que formatea un número, concatena dos cadenas o suma diez elementos cuesta menos que cualquier guardia que pretenda evitarla, incluida la propia lectura de la lista de dependencias. Memoizar esos cálculos añade objetos, aristas y comparaciones para ahorrar un trabajo que ya era despreciable, y el resultado neto es negativo en tiempo y claramente negativo en legibilidad.

// no merece guardia: el calculo es mas barato que la comparacion
const etiqueta = `${nombre} ${apellido}`

// merece guardia: recorre miles de elementos y ordena
const ordenados = useMemo(() => [...items].sort(porFecha), [items])
⚠️
La comparación superficial esconde una premisa

La igualdad superficial solo es correcta si los valores del nivel comparado son estables por identidad, es decir, si el estado subyacente se actualiza de forma inmutable con structural sharing. Si alguien muta un objeto anidado en su sitio, la referencia del nivel superior no cambia, la comparación superficial devuelve verdadero, y el sistema concluye que no hay nada que hacer mientras la interfaz muestra datos viejos. Este es el fallo más difícil de diagnosticar de todo el nivel, porque no produce error ni lentitud: produce una pantalla que simplemente se quedó atrás.

Comparar menos en lugar de comparar mejor

La mejor optimización de una comparación es no necesitarla. Hay tres caminos para llegar ahí, y los tres consisten en reorganizar el estado en lugar de afinar el comparador.

🔑

Normaliza por identificador

Un estado indexado por clave permite suscribirse a una entrada concreta. La comparación se reduce a la identidad de un objeto pequeño y el cono de invalidación se estrecha solo.

✂️

Selecciona primitivos

Un selector que devuelve un número o una cadena se compara en tiempo constante y sin ambigüedad. Preferir varios selectores estrechos a uno ancho suele ser más barato en total.

🧊

Estabiliza la identidad

Si el valor derivado se construye una vez y se reutiliza mientras sus fuentes no cambian, la identidad vuelve a ser un indicador fiable y la comparación superficial deja de hacer falta.

🧭

Versiona en lugar de comparar

Un contador que se incrementa en cada escritura permite decidir con una comparación de enteros. Es la técnica que usan por dentro los grafos reactivos para saltarse recálculos.

La última tarjeta merece atención porque es la que menos se conoce y la que más usan los motores. Un nodo derivado no necesita comparar valores para saber si debe recalcularse: le basta con comparar la versión de sus fuentes con la versión que registró la última vez. Solo si alguna cambió recalcula, y solo si el valor resultante difiere del anterior propaga hacia adelante. Ese doble filtro, versión primero y valor después, es la razón por la que un grafo bien implementado corta la propagación en el punto exacto en el que deja de haber cambio observable, y lo hace con comparaciones de enteros en el noventa por ciento de los casos.

// filtro por version: enteros primero, valores solo si hace falta
function leer(nodo: Nodo): unknown {
  if (nodo.versionVista === fuenteMaxVersion(nodo)) return nodo.valor  // corte barato
  const nuevo = nodo.calcular()
  nodo.versionVista = fuenteMaxVersion(nodo)
  if (Object.is(nuevo, nodo.valor)) return nodo.valor   // corte por igualdad
  nodo.valor = nuevo
  nodo.version++                                        // propaga hacia adelante
  return nuevo
}

Este esquema explica también por qué un nodo derivado puede recalcularse sin que sus dependientes se enteren. Si el nuevo valor resulta idéntico al anterior, la versión del nodo no avanza y la propagación se detiene ahí mismo, aunque el nodo sí haya pagado su recálculo. Es el equivalente exacto de un memo que devuelve la misma referencia, con la diferencia de que aquí el corte es automático y ocurre en el punto óptimo del grafo en lugar de en el punto donde alguien se acordó de ponerlo.

💡
Instrumenta la comparación antes de elegirla

Envuelve tu comparador en un contador y registra dos números en una sesión de uso real: cuántas veces se llamó y cuántas veces devolvió falso. Si casi siempre devuelve falso, no estás memoizando, estás pagando una guardia inútil y conviene quitarla. Si casi siempre devuelve verdadero pero el coste por llamada es alto, el problema no es la frecuencia sino la estrategia y probablemente puedas bajar de profunda a superficial reestructurando el valor. Sin esos dos números, cualquier discusión sobre comparadores es especulación.

La igualdad barata se compra con disciplina de escritura

Comparar por identidad es la operación más rápida que existe en un sistema reactivo, y no cuesta nada en tiempo de ejecución. Pero eso no significa que sea gratis: su precio se paga en otro momento y en otra moneda, en el instante de la escritura y en forma de disciplina. Una referencia solo puede servir como resumen fiable del contenido si el programa entero respeta un contrato, el de que un valor observable jamás se muta en su sitio, y que cualquier cambio produce una referencia nueva junto con referencias nuevas en todo el camino hasta la raíz. Cumplido ese contrato, la comparación de un puntero contiene tanta información como recorrer la estructura completa, porque la identidad se ha convertido en un resumen perfecto del contenido. Roto el contrato, esa misma comparación se vuelve una mentira silenciosa, y para recuperar la corrección hay que volver a recorrer la estructura, es decir, hay que pagar en cada lectura lo que se ahorró en cada escritura. Aquí se ve por qué la inmutabilidad no es una preferencia estética ni un tributo a la programación funcional, sino una inversión con rendimiento medible: se acepta un coste acotado y predecible al escribir, proporcional a la profundidad del camino modificado, a cambio de que todas las comparaciones posteriores, que son órdenes de magnitud más frecuentes, se resuelvan en tiempo constante. Quien muta para ahorrarse una copia superficial no está optimizando: está trasladando el coste desde un lugar donde era pequeño y visible hasta otro donde será grande, difuso e imposible de atribuir.

⚔️ Pon precio a tus comparaciones
  1. Implementa las tres estrategias y mídelas sobre un estado de diez mil nodos; expresa la diferencia entre identidad y profunda en órdenes de magnitud.
  2. Busca en un proyecto real un useMemo cuya lista de dependencias serialice un objeto y calcula si la guardia cuesta más que la función que protege.
  3. Toma un selector que devuelva un objeto literal, cuenta cuántas veces su comparación por identidad falla en una sesión, y reescríbelo como dos selectores de primitivos.
  4. Muta a propósito un objeto anidado y demuestra que la comparación superficial del nivel superior devuelve verdadero mientras la interfaz muestra datos obsoletos.
  5. Implementa el filtro por versión sobre un nodo derivado y verifica que la comparación de valores solo ocurre cuando alguna fuente cambió de versión.