wandres.dev
MODELAR EL STATE · el dominio como valor

Equatable y el coste de comparar

El State de una feature en TCA es casi siempre Equatable, y conviene saber exactamente por qué: no es una convención heredada sino el requisito que sostiene el testing exhaustivo, la deduplicación de efectos y varias piezas de la difusión de cambios. Esta lección explica qué usa realmente la igualdad en TCA moderna —donde `Observation` ya no depende de ella para invalidar vistas—, cómo funciona la conformidad sintetizada por el compilador y cuál es su coste real: una comparación estructural, recursiva y proporcional al tamaño del valor, con atajos importantes cuando el almacenamiento de copia sobre escritura no ha cambiado. Termina con las salidas legítimas cuando comparar sale caro: `@ObservationStateIgnored`, cajas por referencia y conformidades escritas a mano.

⏱ 18 min

La conformidad con Equatable parece la línea más inocente de un State, hasta que uno se pregunta qué está firmando. Está declarando que dos valores de esa feature son comparables por contenido y no por identidad, que esa comparación es total y estructural, y que su coste es proporcional a todo lo que el valor contiene, recursivamente, hasta la última hoja del árbol. En una feature con un puñado de campos escalares eso no significa nada. En una que arrastra veinte mil elementos, una imagen decodificada o un subárbol de treinta pantallas anidadas, significa que cada comparación es un recorrido completo de esa estructura, y que quien la dispare sin darse cuenta ha construido un bucle costoso sin escribir ni un bucle. Entender qué usa la igualdad en TCA, cuándo se ejecuta y qué atajos existen no es una preocupación de micro-optimización prematura: es saber qué coste has aceptado al escribir dos palabras.

🎯 Al terminar esta lección sabrás
  • Enumerar qué partes de TCA dependen realmente de que el State sea Equatable y cuáles ya no.
  • Explicar cómo el compilador sintetiza la igualdad y qué la rompe silenciosamente.
  • Estimar el coste de una comparación estructural y reconocer los atajos de la copia sobre escritura.
  • Aplicar las salidas legítimas cuando comparar es caro sin renunciar a las garantías del framework.

Quién necesita la igualdad, y quién ya no

El primer malentendido que conviene deshacer es que el State sea Equatable para que la vista no se redibuje de más. Eso era cierto en la era de WithViewStore, cuando TCA comparaba el estado observado antes y después para decidir si emitía un cambio. Con @ObservableState la invalidación la gobierna el framework Observation, que rastrea accesos campo a campo: una vista que leyó nombre se invalida cuando se escribe nombre, se compare o no con su valor anterior. La igualdad dejó de ser el mecanismo principal de deduplicación de la interfaz.

Lo que sí sigue dependiendo de ella es sustancial:

Consumidor Para qué usa la igualdad
TestStore exhaustivo Comparar el estado esperado con el real y calcular el diferencial legible del fallo
Efectos con deduplicación Descartar emisiones consecutivas idénticas antes de convertirlas en acciones
Presentaciones y colecciones Detectar altas, bajas y sustituciones al reconciliar hijos
Herramientas de depuración Imprimir únicamente lo que cambió entre dos acciones

Es una lista más corta de lo que la gente cree y aun así decisiva, porque incluye el testing exhaustivo, que es la promesa central de la arquitectura. Un State no comparable convierte el TestStore en un instrumento romo: puedes seguir enviando acciones, pero pierdes la afirmación precisa de qué cambió y el diferencial que hace que un fallo se lea en tres segundos.

Que Observation haya relevado a la igualdad en la invalidación de vistas tiene un efecto secundario que conviene tener presente, porque va en dirección contraria a la intuición heredada: con @ObservableState, escribir en un campo el mismo valor que ya tenía invalida a quien lo observa, porque el rastreo es por acceso de escritura y no por diferencia de contenido. Un reducer que reasigna una colección entera en cada tic de un temporizador, aunque el contenido sea idéntico, provoca trabajo de renderizado real. La disciplina que evita eso no es la igualdad sino la parsimonia en la mutación: escribe solo cuando algo cambia de verdad.

case let .respuesta(articulos):
  let nuevos = IdentifiedArrayOf(uniqueElements: articulos)
  guard nuevos != state.articulos else { return .none }
  state.articulos = nuevos
  return .none

Aquí la igualdad se usa de forma deliberada y explícita, como guardia antes de escribir. Es un uso honesto y frecuentemente rentable: una comparación que en el caso favorable termina pronto a cambio de evitar un ciclo de renderizado completo.

Qué hace realmente el == sintetizado

Cuando declaras struct State: Equatable sin escribir el operador, el compilador sintetiza una comparación que recorre las propiedades almacenadas en orden de declaración y devuelve falso en la primera que difiera. Las propiedades computadas no participan, lo cual confirma lo que vimos sobre estado derivado: computar es gratis también aquí. La síntesis es recursiva y exige que todos los campos sean Equatable; basta uno que no lo sea —una closure, un tipo de una biblioteca ajena que no conforma— para que la síntesis falle, y el error del compilador suele aparecer lejos, en el punto donde alguien intentó usar el TestStore.

@ObservableState
struct State: Equatable {
  var consulta: String = ""
  var articulos: IdentifiedArrayOf<Articulo> = []
  var seleccionado: Articulo.ID? = nil
}

La comparación de este valor es, en el peor caso, lineal en el número de artículos y en la longitud de la consulta. En el mejor caso es constante, y aquí aparece el detalle que más cambia la intuición: la biblioteca estándar de Swift aprovecha la copia sobre escritura. Cuando dos arrays o dos cadenas comparten el mismo almacenamiento subyacente —porque uno es copia del otro y nadie lo ha mutado—, la igualdad detecta la identidad del búfer y responde afirmativamente sin recorrer nada. En un reducer que muta un solo campo del State, todos los demás campos siguen apuntando al mismo búfer que antes, así que la comparación de una colección grande intacta es efectivamente gratuita.

💡
Lo caro no es comparar mucho, es comparar mucho y distinto

El coste temido aparece cuando dos colecciones grandes tienen contenidos iguales pero almacenamientos distintos: entonces no hay atajo y se recorre todo para concluir que no había cambio. Es el caso típico de una respuesta de red que reconstruye la lista entera en cada refresco. La solución no suele ser tocar Equatable, sino dejar de reconstruir: fusiona en la colección existente en vez de sustituirla, y el atajo de la copia sobre escritura volverá a funcionar.

El orden de declaración de los campos también importa más de lo que se supone, aunque su efecto sea modesto. Como la comparación sintetizada devuelve falso en la primera diferencia, declarar primero los campos pequeños y volátiles —un enum de fase, un contador, una bandera— y después las colecciones grandes hace que la mayoría de comparaciones desiguales terminen en las primeras líneas. No es una técnica que salve un perfil por sí sola, pero es gratis y va en la dirección correcta.

Cuando la síntesis falla y qué significa

Un State deja de ser Equatable en cuanto uno solo de sus campos no lo es, y el mensaje del compilador rara vez aparece donde está la causa: suele saltar mucho más arriba, en el State del padre que contiene a la hija, o directamente en el test que intentaba usar el TestStore. Los tres culpables habituales son siempre los mismos, y cada uno indica un problema distinto.

Una closure almacenada no es comparable y nunca lo será, y su presencia significa que se coló comportamiento en el valor. La corrección no es envolverla ni ignorarla: es expresarla como una acción, según el criterio de admisión. Un tipo de una biblioteca ajena que no conforma indica una frontera mal trazada: ese tipo pertenece a la capa de datos y el State debería guardar un modelo de dominio propio, traducido en el borde. Y un tipo existencial o genérico sin restricción indica que el modelo pide más precisión de la que se le está dando.

// El error aparecerá en el padre, no aquí
@ObservableState
struct State: Equatable {
  var articulos: [Articulo] = []
  var formateador: NumberFormatter = .init()
}

Ese NumberFormatter es el caso de manual: no es estado, es una herramienta de presentación con identidad de objeto, y su sitio es una dependencia o una constante estática de la vista. Cada vez que la síntesis de Equatable falla, merece la pena tratarlo como un diagnóstico gratuito del modelo en lugar de como un obstáculo a esquivar: el compilador acaba de señalar, sin que se lo pidieras, el campo que no era estado.

Las salidas legítimas cuando comparar sale caro

Antes de aplicar ninguna, mide. El coste de comparación se vuelve visible en perfiles como tiempo dentro del operador de igualdad sintetizado, disparado por el TestStore o por la reconciliación de hijos, y casi siempre en features con colecciones muy grandes o con datos binarios embebidos. Confirmado el diagnóstico, hay tres salidas y cada una tiene su precio.

La primera es no guardar en el State lo que no es estado: una imagen decodificada, un búfer de audio, un modelo de aprendizaje cargado. Ese material pertenece a una dependencia o a una caché externa, y el State debe guardar únicamente el identificador con el que se recupera. Es la solución correcta en la mayoría de los casos y no es un truco de rendimiento sino la aplicación del criterio de admisión.

La segunda es excluir del valor lo que no debe influir en la igualdad, envolviendo el dato en una caja por referencia cuya igualdad se defina por identidad.

final class Caja<Valor>: Equatable {
  let valor: Valor
  init(_ valor: Valor) { self.valor = valor }
  static func == (lhs: Caja, rhs: Caja) -> Bool { lhs === rhs }
}

@ObservableState
struct State: Equatable {
  var articuloID: Articulo.ID
  var miniatura: Caja<DatosDeImagen>?
}

La comparación pasa a ser de punteros y es constante, pero el precio es real y conviene decirlo: el TestStore ya no puede afirmar diferencias sobre el contenido de la caja, y dos cajas con el mismo contenido se consideran distintas. Es aceptable para datos opacos que ninguna aserción va a inspeccionar; es un error para datos de dominio.

La tercera es escribir el == a mano comparando solo los campos que determinan la identidad lógica del estado. Es la más peligrosa, porque introduce una desviación entre lo que el tipo dice y lo que el framework observa: si dos estados se declaran iguales pero difieren en un campo, el TestStore dejará pasar en silencio un cambio que sí ocurrió, y eso convierte una prueba en un adorno. Resérvala para casos donde el campo ignorado sea genuinamente irrelevante y documenta el motivo junto al operador.

extension Reproductor.State {
  // Se ignora `formaDeOnda` a proposito: son megabytes de muestras
  // que ninguna asercion inspecciona. Ver ADR-114.
  static func == (lhs: Self, rhs: Self) -> Bool {
    lhs.pistaID == rhs.pistaID
      && lhs.posicion == rhs.posicion
      && lhs.reproduciendo == rhs.reproduciendo
  }
}

Las tres salidas forman una escala de daño creciente y conviene recorrerla en orden, deteniéndose en la primera que resuelva el problema medido. Si nunca llegas a la tercera, mejor: casi todos los casos que parecían exigir una igualdad a medida se disuelven al sacar del State lo que nunca fue estado.

🪶

Mantén el State ligero

La mejor optimización de la igualdad es no tener qué comparar. Identificadores en lugar de blobs, referencias a caché en lugar de contenidos, y el material pesado fuera del valor.

⚖️

Respeta el contrato

Toda salida que haga la igualdad más laxa debilita el testing exhaustivo en la misma proporción. Acepta ese intercambio de forma consciente o no lo aceptes.

flowchart TB
S[State Equatable] --> T[TestStore compara y calcula el diferencial]
S --> D[Deduplicacion de efectos]
S --> P[Reconciliacion de hijos y presentaciones]
O[Observation] -->|invalida por acceso a campo| V[Vista]
S -.->|coste lineal si el buffer cambio| C[Recorrido estructural]
style S fill:#89b4fa,color:#11111b
style C fill:#fab387,color:#11111b
La igualdad es el contrato que convierte un test en una demostración

Merece la pena entender qué se pierde exactamente al debilitar la igualdad, porque es más de lo que parece. El testing exhaustivo de TCA no consiste en comprobar que ciertas cosas cambiaron: consiste en comprobar que solo cambiaron las que declaraste. Esa cláusula de exclusividad es lo que distingue una prueba de una demostración, y descansa por completo en que la comparación entre el estado esperado y el real sea total. Cuando escribes un == que ignora un campo, no estás haciendo la prueba un poco menos precisa: estás abriendo un agujero por el que puede colarse cualquier mutación de ese campo, para siempre, en todos los tests presentes y futuros de la feature, sin que nadie reciba un aviso. El fallo no se manifestará como un test rojo sino como un test verde que no significaba nada, que es la forma más cara de fallo posible porque consume la confianza sin gastarla visiblemente. Por eso la jerarquía de soluciones importa tanto: sacar el dato pesado del State no cuesta ninguna garantía, envolverlo en una caja opaca cuesta la inspección de ese dato, y reescribir la igualdad cuesta la exclusividad entera. Hay una lectura más general escondida aquí, y es que en TCA el rendimiento y la corrección no son ejes independientes que se negocian: comparten la misma palanca, que es el tamaño y la forma del State. Un State mínimo, normalizado y sin material ajeno se compara rápido porque está bien modelado, y se testea con precisión por la misma razón. Cuando notes que la igualdad te duele, la pregunta correcta casi nunca es cómo hacer la comparación más barata, sino qué está haciendo dentro de tu estado algo que no era estado.

⚔️ Audita el coste de igualdad de tu feature más pesada
  1. Localiza la feature con el State más grande y enumera sus campos por tamaño esperado en producción, no en las pruebas.
  2. Comprueba si alguna colección se reconstruye entera en cada refresco. Cámbiala por una fusión sobre la colección existente y observa si el atajo de la copia sobre escritura vuelve a aplicarse.
  3. Busca material que no sea estado: imágenes decodificadas, datos binarios, objetos cargados. Sácalo del State y deja solo el identificador.
  4. Si tras eso la comparación sigue pesando, prueba una caja por referencia para el dato opaco y anota explícitamente qué aserciones del TestStore acabas de renunciar a poder escribir.
  5. Escribe un test que afirme una mutación de un campo excluido. Si pasa cuando no debería, acabas de ver en directo lo que cuesta debilitar la igualdad.