Elevar el estado: dónde debe vivir cada dato
El criterio del ancestro común más bajo, los costes reales de subir de más, cuándo un dato debe bajar de nuevo y por qué el estado derivado nunca se almacena. La decisión de colocación que define la arquitectura de una pantalla.
Elegir el property wrapper correcto es la parte fácil. La decisión que de verdad determina si una pantalla envejece bien es otra: a qué altura del árbol de vistas vive cada dato. Colócalo demasiado abajo y dos vistas que deberían coincidir divergen, o el dato se evapora al navegar. Colócalo demasiado arriba y una pulsación local invalida media pantalla, los inicializadores se llenan de parámetros de paso y todo queda acoplado a todo. Hay un criterio exacto para acertar, hay señales claras de haber subido de más, y hay una familia entera de datos que nunca debió abandonar la vista que los usa.
- Aplicar la regla del ancestro común más bajo para colocar cada dato.
- Reconocer los tres síntomas de un estado elevado por encima de lo necesario.
- Identificar el estado puramente presentacional que debe permanecer local.
- Sustituir el estado derivado almacenado por una propiedad calculada.
El criterio: el ancestro común más bajo
La regla cabe en una frase: un dato vive en el ancestro común más bajo de todas las vistas que lo leen o lo escriben. Ni un nivel más arriba, ni uno más abajo. El procedimiento para aplicarla es igual de mecánico: enumera todas las vistas que necesitan el dato, sube por el árbol hasta el primer nodo que las contiene a todas, y ese nodo es su dueño.
flowchart TD P[Pantalla] --> L[Lista de filtros] P --> R[Resultados] P --> T[Barra de total] L --> C1[Chip de categoria] R --> F1[Fila de resultado] F1 --> E1[Estado expandido local] P -.-> S[Aqui vive el filtro por ser ancestro comun mas bajo]
En el diagrama, el filtro seleccionado lo escriben los chips y lo leen los resultados y el total: su ancestro común más bajo es la pantalla, y ahí debe declararse. En cambio, si una fila puede expandirse, ese Bool solo lo lee y escribe la propia fila: su ancestro común es ella misma, y elevarlo sería un error.
struct PantallaCatalogo: View {
@State private var filtro: Categoria? = nil // leido y escrito por varios hijos
var body: some View {
VStack {
BarraFiltros(seleccion: $filtro) // escribe
Resultados(filtro: filtro) // lee
BarraTotal(filtro: filtro) // lee
}
}
}
Hay un matiz que la regla, tal cual, no captura: escribir y leer no pesan igual. Una vista que solo lee puede recibir el valor ya calculado, mientras que una que escribe necesita un camino de vuelta hasta la fuente. Por eso la barra de filtros recibe $filtro y los resultados reciben filtro a secas: la asimetría de la firma documenta quién manda sobre el dato sin necesidad de leer una línea de implementación.
El paso de @Binding hacia abajo no es una concesión ni un mal necesario: es la manera explícita de decir que el hijo escribe en un dato que no posee. Cuando ese binding atraviesa más de dos niveles de vistas que no lo usan, la regla te está avisando de que el transporte debería ser otro, y esa es la conversación del entorno más adelante.
Los costes de subir de más
Elevar por si acaso parece inofensivo y no lo es. Tiene tres costes concretos y observables.
Invalidación en cascada
Un dato en la raíz que cambia con cada pulsación reevalúa el body de la raíz y, con él, todo lo que no esté aislado por observación granular. El scroll a sesenta cuadros por segundo se convierte en trabajo de la pantalla entera.
Transporte por tubería
Cada nivel intermedio se ve obligado a declarar un parámetro que no usa, solo para pasarlo. Los inicializadores crecen, las previews se vuelven imposibles de escribir y cualquier cambio de firma se propaga a media pantalla.
Acoplamiento innecesario
Una vista que recibe cinco parámetros solo puede existir dentro de esa jerarquía concreta. Deja de ser un componente reutilizable y se convierte en una pieza soldada a un contexto.
El síntoma que resume los tres es una firma como esta, donde solo el primer parámetro se usa de verdad y los otros viajan de paso:
// Olor a estado elevado de mas: cuatro de los cinco parametros solo pasan de largo
FilaProducto(producto: p,
filtro: filtro,
orden: orden,
seleccion: $seleccion,
modoEdicion: $modoEdicion)
Conviene ver la decisión en su forma abstracta, porque entonces deja de ser cuestión de gusto. Colocar un dato en el árbol es elegir el conjunto de vistas que dependen de él, y ese conjunto es exactamente el subárbol que cuelga de su dueño. Subir un dato un nivel no lo hace más accesible: hace más grande su radio de invalidación y más ancha su superficie de acoplamiento. La regla del ancestro común más bajo es, en realidad, un principio de mínima autoridad aplicado a los datos, el mismo que en seguridad dice que cada componente reciba el permiso más pequeño que le permita funcionar. Y hay una analogía todavía más útil: la normalización de bases de datos. Cuando el mismo dato vive en dos sitios, aparece la anomalía de actualización, dos copias que divergen; la solución es una única fuente de verdad y todo lo demás derivado de ella. En SwiftUI ocurre literalmente lo mismo, con la vuelta de tuerca de que aquí la fuente única tiene además una posición en el árbol, y esa posición determina el coste de rendimiento y el grado de acoplamiento. De ahí que la pregunta correcta al diseñar una pantalla no sea qué wrapper uso, sino cuál es el conjunto mínimo de vistas que deben enterarse de este cambio, porque la respuesta a esa segunda pregunta fija la primera, fija el coste del scroll y fija cuánto podrás reutilizar cada componente el día que rediseñes la pantalla.
Bajar el estado: lo que nunca debió subir
La operación inversa se practica poco y hace falta con la misma frecuencia. Hay una familia de datos que es puramente presentacional, cuyo alcance real es una sola vista y que solo llegó arriba por inercia: si una fila está expandida, qué campo tiene el foco, si un menú está abierto, la posición del scroll, el texto de un borrador antes de confirmarse, el estado de una animación.
El criterio para bajarlos es el mismo de antes leído al revés. Si al eliminar el dato del ancestro y declararlo local ninguna otra vista se rompe, es que sobraba arriba. La prueba es directa y no admite discusión.
struct FilaProducto: View {
let producto: Producto
@State private var expandida = false // nadie mas necesita saberlo
var body: some View {
VStack(alignment: .leading) {
Text(producto.nombre)
.onTapGesture { expandida.toggle() }
if expandida { DetalleProducto(producto: producto) }
}
}
}
Bajar estado tiene además un efecto de rendimiento que rara vez se anticipa. Mientras el indicador de expansión viva en la fila, abrirla invalida esa fila y nada más; el mismo Bool guardado en la pantalla como un conjunto de identificadores expandidos invalida el body de la pantalla en cada toque y, con él, todas las filas visibles. En una lista de doscientos elementos la diferencia entre las dos versiones no es de estilo, es de fotogramas por segundo.
Hay una excepción que conviene nombrar, porque es la que produce dudas legítimas: si el estado presentacional debe sobrevivir a la desaparición de la vista, deja de ser presentacional. La posición del scroll que quieres restaurar tras un cambio de pestaña, o el borrador que no debe perderse al navegar atrás, son datos con vida más larga que su vista y, por la lección de identidad, no pueden vivir en un @State que se destruye con ella.
Que dos vistas necesiten un dato no autoriza a ponerlo en la raíz ni en el entorno. Autoriza a ponerlo en su ancestro común, que casi siempre está mucho más abajo de lo que parece. El entorno es para lo verdaderamente transversal, no para ahorrarse dos parámetros.
El estado derivado no se almacena
El último error de colocación es almacenar lo que se puede calcular. Cada dato derivado que guardas es una segunda fuente de verdad que hay que mantener sincronizada a mano, y toda sincronización manual acaba desincronizándose.
// MAL: dos fuentes de verdad que hay que mantener a mano
@State private var tareas: [Tarea] = []
@State private var pendientes: Int = 0 // se olvidara de actualizarse
// BIEN: una fuente, el resto se calcula en cada evaluacion
@State private var tareas: [Tarea] = []
private var pendientes: Int { tareas.filter { !$0.hecha }.count }
La misma disciplina se aplica al modelo, donde las derivaciones caras encuentran su sitio natural. Una propiedad calculada de una clase @Observable sigue participando del seguimiento: leerla dentro de un body registra como dependencia todas las propiedades almacenadas que ese cálculo consulte, ni una más.
@Observable
final class Cesta {
var lineas: [Linea] = [] // unica fuente de verdad
var total: Decimal { lineas.reduce(0) { $0 + $1.importe } } // derivacion, no estado
}
La propiedad calculada no es un truco de estilo: elimina por construcción la posibilidad de que las dos cifras discrepen. Si el cálculo resulta caro, la respuesta no es volver a almacenarlo en la vista sino subirlo al modelo y memorizarlo allí, donde la invalidación es explícita y verificable. Esa frontera entre lo que se calcula al vuelo en la vista y lo que el dominio mantiene es justamente el asunto de la última lección del nivel.
- Toma una pantalla real y dibuja su árbol de vistas anotando qué vista lee y escribe cada dato.
- Calcula el ancestro común más bajo de cada dato y compáralo con dónde está declarado hoy.
- Localiza un parámetro que solo viaje de paso por dos o más niveles y decide si baja o pasa al entorno.
- Encuentra un
@Statepresentacional elevado y bájalo; comprueba que nada se rompe. - Sustituye un dato derivado almacenado por una propiedad calculada y razona qué anomalía acabas de hacer imposible.