Estado grande: dividir features y aligerar el árbol de datos
Todo coste que crece con los datos tiene el mismo origen: un `State` que acumula cosas que nadie dibuja. Esta lección fija el criterio de admisión —qué merece vivir en el estado y qué no—, muestra cómo dividir una feature obesa en subdominios para que la igualdad, la observación y los tests dejen de recorrer el árbol entero, trata las colecciones grandes con identidad estable, proyecciones e identificadores en lugar de copias, y expone las tres salidas legítimas para los datos pesados: sacarlos a una dependencia, excluirlos de la observación o darles una igualdad barata y honesta.
El State de una feature empieza siendo una descripción de lo que se ve en pantalla y termina siendo un vertedero. El mecanismo es siempre el mismo y nadie lo comete de golpe: hace falta un dato para una decisión y se guarda; hace falta evitar recargarlo y se guarda otra vez, ya decodificado; hace falta el original para poder reintentar y se guarda también; y un año después esa estructura tiene cuarenta campos, cuatro colecciones y un par de megabytes de contenido binario, cada uno de ellos justificado por un motivo razonable en su momento. Ninguno de esos pasos es un error, y sin embargo el resultado sí lo es, porque todo lo que crece con el tamaño del estado —la igualdad estructural, el diferencial que calcula el TestStore, la persistencia, la memoria residente, el tiempo de compilación de las macros— crece de golpe con él. Esta lección aporta lo único que detiene la deriva: un criterio explícito de admisión, y tres salidas ordenadas para todo lo que no pase ese criterio.
- Aplicar un criterio de admisión que decida, para cada dato, si pertenece al
Stateo a otro sitio. - Dividir una feature obesa en subdominios y explicar qué coste concreto reduce cada corte.
- Manejar colecciones grandes con identidad estable, proyecciones por identificador y paginación en lugar de copias.
- Elegir con criterio entre sacar el dato a una dependencia, excluirlo de la observación o darle una igualdad barata.
El criterio de admisión
Un dato merece estar en el State si se cumple al menos una de dos condiciones: alguna vista lo dibuja, o alguna rama del reducer lo consulta para decidir. Si no se cumple ninguna, no es estado: es un dato que estaba a mano. La prueba es incómoda de aplicar porque casi todo podría consultarse algún día, así que hay que aplicarla en presente y no en potencial.
| Dato | ¿Va en el State? |
Dónde va si no |
|---|---|---|
| Título, contadores, banderas de carga | Sí | — |
| Identificadores de los elementos visibles | Sí | — |
| La respuesta cruda del servidor sin decodificar | No | Se descarta tras decodificar |
| Imágenes, PDF, audio, contenido binario | No | Una dependencia de almacenamiento indexada por identificador |
| Cachés de medidas o de valores derivados | No, y si va, excluida | @ObservationStateIgnored con un comentario |
| Listas completas que solo se muestran paginadas | Parcialmente | Solo la página cargada, más el cursor |
| Copias del mismo modelo en varias features | No | Una sola copia, compartida con @Shared |
La última fila es la que produce los estados más grandes y la que menos se ve venir. Cuando dos features necesitan el mismo catálogo, el reflejo es darle una copia a cada una, y a partir de ese momento tienes dos verdades que sincronizar y dos árboles que comparar en cada acción. Una referencia compartida no solo elimina la duplicación de memoria: elimina la clase entera de defectos de sincronización, que suele ser el motivo principal para adoptarla.
Dividir para que el árbol deje de crecer a lo ancho
Una feature con cuarenta campos no se arregla borrando campos: se arregla reconociendo que ahí dentro hay tres dominios distintos que comparten pantalla por casualidad. El corte correcto casi nunca sigue la disposición visual; sigue las líneas por las que los datos cambian juntos. Los campos del formulario cambian con las pulsaciones del usuario; la colección cambia con las respuestas de la red; las preferencias no cambian casi nunca. Tres ritmos, tres dominios.
@Reducer
struct Panel {
@ObservableState
struct State: Equatable {
var buscador = Buscador.State() // cambia a 60 pulsaciones por minuto
var resultados = Resultados.State() // cambia al llegar una respuesta
var ajustes = Ajustes.State() // cambia una vez al mes
}
var body: some ReducerOf<Self> {
Scope(state: \.buscador, action: \.buscador) { Buscador() }
Scope(state: \.resultados, action: \.resultados) { Resultados() }
Scope(state: \.ajustes, action: \.ajustes) { Ajustes() }
Reduce { state, action in
// solo la coordinacion entre dominios vive aqui
return .none
}
}
}
Ese corte no es cosmético y conviene enumerar exactamente qué compra. La igualdad deja de recorrer el árbol entero en los sitios donde solo importa un subdominio. Las vistas se conectan con scope y sus lecturas quedan confinadas a su rama, de modo que teclear en el buscador no puede invalidar nada de resultados aunque alguien escriba mal el código, porque sencillamente no tiene acceso. Los tests dejan de instanciar el mundo para probar una validación. Y los tiempos de compilación bajan, porque las macros generan menos código por unidad y el comprobador de tipos resuelve expresiones más pequeñas.
El corte equivocado es el que obliga a las tres piezas a hablarse constantemente. Si cada acción del buscador tiene que emitir una delegate que el padre traduce en otra acción para resultados, y resultados responde con otra hacia arriba, has multiplicado el número de nodos que cada evento atraviesa y el número de acciones por interacción, que son precisamente los dos costes de la primera lección. La señal de un corte sano es que la mayoría de las acciones de un subdominio se resuelven dentro de él sin subir; si la mayoría sube, el límite estaba en otro sitio.
Colecciones grandes
Una colección en el State tiene tres costes distintos y conviene atacarlos por separado. El de búsqueda se resuelve con IdentifiedArrayOf, que indexa por identificador y convierte la localización de un elemento en tiempo constante; una lista plana obliga a un recorrido lineal por cada acción dirigida a un elemento, y eso multiplicado por el número de acciones es un coste cuadrático escondido. El de comparación se resuelve reduciendo el tamaño del elemento, no el de la colección: si cada modelo lleva un campo grande que nadie dibuja en la lista, la comparación de diez mil elementos lo recorre diez mil veces.
El tercero es el más común y el que más gente resuelve al revés: los derivados. Guardar en el estado la lista filtrada además de la completa duplica la memoria, duplica el coste de igualdad y crea una segunda verdad que hay que recalcular en cada mutación. La alternativa correcta es guardar una sola colección y derivar una proyección de identificadores, que es barata de calcular y barata de comparar.
@ObservableState
struct State: Equatable {
var elementos: IdentifiedArrayOf<Elemento> = []
var filtro = ""
var pagina: Cursor?
// Deriva identificadores, no copias de los modelos.
var visibles: [Elemento.ID] {
filtro.isEmpty
? Array(elementos.ids)
: elementos.filter { $0.titulo.localizedStandardContains(filtro) }.map(\.id)
}
}
La paginación cierra el asunto por arriba: si el servidor puede devolver cien mil filas, la pregunta no es cómo compararlas rápido sino por qué están todas en memoria. Guardar solo las páginas cargadas y un cursor mantiene el estado acotado por lo que el usuario ha llegado a ver, que es una cota mucho mejor que el tamaño de la base de datos remota.
flowchart TD D[Tengo un dato voluminoso] --> Q1[Lo dibuja alguna vista] Q1 -->|si| Q2[Cabe acotarlo por paginacion] Q1 -->|no| Q3[Lo consulta alguna rama del reducer] Q2 -->|si| P[Guarda solo la pagina y el cursor] Q2 -->|no| ID[Guarda identificadores y resuelve al dibujar] Q3 -->|no| F[Sacalo a una dependencia] Q3 -->|si| E[Dejalo pero excluyelo de la observacion] style F fill:#a6e3a1,color:#11111b style P fill:#89b4fa,color:#11111b
Las tres salidas para lo pesado
Cuando un dato no pasa el criterio de admisión hay exactamente tres destinos legítimos, en este orden de preferencia.
Sacarlo a una dependencia
El estado guarda el identificador; un cliente inyectado resuelve el contenido. Sigue siendo sustituible en tests y desaparece de la igualdad, del diferencial y de la memoria del árbol.
Excluirlo de la observación
@ObservationStateIgnored para cachés y memorias auxiliares que ninguna vista dibuja. Barato e inmediato, pero deja el peso dentro del valor y no ayuda con la igualdad.
Darle igualdad barata
Un == propio que compare identificador o versión en lugar de contenido. Correcto solo si el contenido es inmutable para ese identificador, y hay que escribirlo junto al tipo.
Compartirlo en vez de copiarlo
Un @Shared en lugar de N copias del mismo catálogo. Elimina la duplicación y, con ella, toda una familia de defectos de sincronización.
La primera salida es casi siempre la correcta y suele parecer la más trabajosa, lo cual explica por qué se elige tan poco. Merece la pena verla escrita para comprobar cuán poco código es en realidad.
@DependencyClient
struct AlmacenDeAdjuntos {
var cargar: @Sendable (UUID) async throws -> Data
var guardar: @Sendable (UUID, Data) async throws -> Void
}
@ObservableState
struct State: Equatable {
var adjuntoID: UUID? // ocho megabytes convertidos en dieciseis bytes
var cargando = false
}
La tercera salida exige una advertencia proporcional a su comodidad. Un == que compara solo el identificador está mintiendo sobre la igualdad, y esa mentira se propaga a todo lo que dependa de ella: el TestStore dejará de detectar cambios de contenido, onChange no se disparará y removeDuplicates descartará actualizaciones legítimas. Es aceptable únicamente cuando el contenido es inmutable por identificador —un adjunto descargado nunca cambia sin cambiar de identificador— y siempre debe ir acompañada de un comentario que explique el invariante del que depende, porque quien lo rompa dentro de dos años no tendrá ninguna otra pista.
Hay una manera más profunda de entender el criterio de admisión que la meramente prestacional, y es la que hace que valga la pena defenderlo incluso cuando el rendimiento no aprieta. El State de una feature no es un almacén: es la definición de qué significa esa feature, expresada como el conjunto exacto de configuraciones en las que puede encontrarse. Cada campo que añades multiplica ese conjunto, y lo multiplica te guste o no, porque el sistema de tipos no distingue entre las combinaciones que tienen sentido y las que no. Dos banderas booleanas independientes son cuatro estados posibles, de los cuales quizá tres sean legales; añade una tercera y tienes ocho con cinco legales; añade un campo opcional y una colección y ya no puedes ni enumerarlos. De ahí se sigue algo que rara vez se dice en voz alta: los costes de rendimiento que esta lección ataca no son un problema aparte del problema de diseño, son su manifestación medible. Una comparación cara es cara porque hay mucho que comparar, y hay mucho que comparar porque el espacio de estados es grande, y ese mismo espacio grande es el que produce los defectos que ningún test cubre, las pantallas que se quedan en configuraciones imposibles y los reducers que nadie se atreve a tocar. La consecuencia práctica es que el trabajo de dividir features y sacar datos fuera nunca es solo optimización, aunque se emprenda por motivos de velocidad: es reducción del espacio de estados, y por tanto reducción simultánea del coste de comparar, del coste de razonar, del coste de testear y del número de configuraciones ilegales alcanzables. Es de los pocos sitios en ingeniería donde no hay compromiso que negociar, y por eso conviene hacerlo antes de que el perfilador te obligue: un estado pequeño no es un estado optimizado, es un estado que alguien entendió.
- Enumera todos los campos del
Statede tu feature más pesada y marca, para cada uno, si lo dibuja alguna vista o lo consulta alguna rama del reducer. Los que no cumplan ninguna condición son candidatos a salir. - Estima el tamaño en memoria de los tres campos mayores. Elige el que peor relación tenga entre tamaño y frecuencia de uso y sácalo a una dependencia indexada por identificador.
- Busca colecciones derivadas guardadas junto a la original y conviértelas en proyecciones de identificadores. Mide el efecto en el tiempo de la suite de tests, que es el detector más sensible.
- Identifica en la feature tres grupos de campos que cambien a ritmos distintos y propón el corte en subdominios. Comprueba cuántas acciones tendrían que subir al padre; si son la mayoría, el corte está mal.
- Si aplicas una igualdad barata, escribe el invariante del que depende en un comentario y añade un test que falle si ese invariante deja de cumplirse.