Bindable en la vista: conectar TextField, Toggle y Picker al store
Con la Action colapsada y el reducer de binding colocado, falta la mitad visible del mecanismo: cómo obtiene la vista un Binding que SwiftUI acepte sin construirlo a mano. La respuesta es la macro Bindable de SwiftUI aplicada al store, que habilita la proyección con dolar sobre cualquier propiedad del estado observable y produce un binding cuya escritura no muta nada sino que envía una acción. Esta lección recorre los controles habituales, explica qué ocurre exactamente en cada pulsación de tecla, resuelve el caso de tipos que no encajan directamente y da la variante de compatibilidad para versiones antiguas del sistema.
Un control de SwiftUI no sabe nada de reducers: pide un Binding y escribe en él. Toda la elegancia del nivel se juega en cómo se fabrica ese Binding sin abrir una segunda puerta de mutación. La respuesta de TCA es que el propio Store sea la fuente de la proyección: declarado con @Bindable, expone bindings sobre las propiedades de su estado observable cuya lectura es directa y cuya escritura, en lugar de asignar, construye y envía el caso binding. Lo que la vista escribe se lee exactamente igual que un binding a un modelo, y lo que ocurre por debajo sigue siendo el ciclo unidireccional completo. Esta lección examina esa costura desde el lado de la interfaz.
- Declarar el store con
@Bindabley producir bindings con la proyección$store.campopara los controles habituales. - Describir con precisión el recorrido de una pulsación de tecla desde el control hasta el estado y de vuelta.
- Resolver los casos en que el tipo del control no coincide con el tipo del estado sin romper la disciplina.
- Aplicar la variante de compatibilidad basada en
Perceptionpara versiones anteriores del sistema.
La proyección $store.campo
@Bindable es una macro de SwiftUI, no de TCA: sirve para cualquier objeto observable y habilita la proyección con el símbolo del dólar sobre sus propiedades. TCA hace que Store sea observable y que su estado sea alcanzable por miembro dinámico, de modo que la combinación funciona sin ninguna pieza intermedia.
struct PerfilView: View {
@Bindable var store: StoreOf<Perfil>
var body: some View {
Form {
Section("Identidad") {
TextField("Nombre", text: $store.nombre)
TextField("Correo", text: $store.correo)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
}
Section("Preferencias") {
Toggle("Recibir notificaciones", isOn: $store.notificaciones)
Picker("Visibilidad", selection: $store.visibilidad) {
ForEach(Visibilidad.allCases) { v in
Text(v.titulo).tag(v)
}
}
Slider(value: $store.volumen, in: 0...1)
Stepper("Copias: \(store.copias)", value: $store.copias, in: 1...9)
}
Button("Guardar") { store.send(.guardarPulsado) }
}
}
}
Cuatro observaciones sobre este cuerpo. La lectura sin dólar —store.copias dentro de la interpolación— accede al valor y suscribe la vista a esa propiedad concreta. La lectura con dólar produce el binding. El botón no usa binding porque pulsar no es escribir un valor: es un suceso con nombre, y conserva su caso propio. Y el Picker exige que el tipo de la selección coincida exactamente con el de las etiquetas del tag, requisito de SwiftUI que no tiene nada que ver con TCA pero que produce el error más frecuente de esta lección: un Picker que no selecciona nada suele ser un tag con tipo distinto al de la propiedad.
Qué ocurre en una pulsación de tecla
Conviene tener el recorrido completo en la cabeza, porque explica a la vez por qué esto es seguro y por qué el orden del body importaba tanto en la lección anterior.
sequenceDiagram participant U as Usuario participant TF as TextField participant B as Binding proyectado participant S as Store participant BR as BindingReducer U->>TF: escribe una letra TF->>B: asigna el texto nuevo B->>S: send del caso binding con key path y valor S->>BR: entrega la accion BR->>S: escribe la propiedad del estado S->>TF: la observacion invalida y redibuja TF->>U: muestra el texto actualizado
El punto crítico es que el set del binding no escribe en el estado: envía. La escritura ocurre después, dentro del reducer, en el único lugar autorizado. Por eso una edición aparece en _printChanges como cualquier otra acción, por eso es interceptable, y por eso un TestStore puede afirmar sobre ella. La vista nunca sostiene una copia mutable del estado; sostiene una lente de lectura y un canal de envío que la sintaxis disfraza de propiedad mutable.
De ahí se sigue una regla práctica sobre cuándo declarar @Bindable y cuándo no. La macro solo hace falta si la vista va a proyectar; una vista que únicamente lee y envía acciones con nombre no la necesita y puede declarar el store como constante, lo cual documenta de un vistazo que ahí no hay campos editables.
struct ResumenView: View {
let store: StoreOf<Perfil> // solo lee y envia
var body: some View {
VStack {
Text(store.nombre)
Button("Editar") { store.send(.editarPulsado) }
}
}
}
Escribir en cada tecla y redibujar en cada escritura suena a bucle, y con el TCA anterior a la observación había que vigilarlo. Con @ObservableState la invalidación es por propiedad accedida: teclear en el campo de correo invalida solo a quien lea correo. La sección que muestra el nombre no se reevalúa. Si notas que la pantalla entera se redibuja con cada tecla, el culpable no es el binding sino algún cuerpo de vista que lee más estado del que muestra, típicamente pasando el estado completo a una subvista en lugar de rebanarlo.
Cuando el tipo del control no encaja
A veces el control pide un tipo distinto del que guarda el estado: un campo de texto que edita un número, un Toggle sobre un valor opcional, un selector cuyo dominio es más rico. Hay tres respuestas, y elegir la correcta es el juicio profesional de esta lección.
La primera es cambiar el estado para que guarde lo que el usuario realmente edita. Un campo de importe se edita como texto, así que el estado guarda un String y la conversión a número vive en la validación o en el momento del envío. Esta es casi siempre la respuesta buena, porque modela el formulario como lo que es —una zona de trabajo textual— y evita el borrado de lo que el usuario está escribiendo a medias.
La segunda es usar el formateador del propio control, que SwiftUI ofrece de serie y que resuelve el caso numérico sin conversiones manuales.
TextField("Importe", value: $store.importe, format: .number)
DatePicker("Fecha", selection: $store.fecha, displayedComponents: .date)
La tercera es derivar un binding a partir del proyectado, transformando lectura y escritura. Es legítima porque la escritura sigue terminando en un envío, pero conviene usarla solo cuando la transformación es total y sin pérdida.
// Toggle sobre un opcional: presente o ausente
Toggle("Con recordatorio", isOn: Binding(
get: { store.recordatorio != nil },
set: { store.send(.recordatorioActivado($0)) }
))
Fíjate en que este último caso ya no es un binding de campo sino un gesto con significado —activar algo, no escribir un valor— y por eso vuelve a merecer un caso con nombre en pasado. La frontera es exactamente esa: si la transformación cambia el significado, has salido del territorio del binding.
Un cuarto caso merece mención aparte porque se presenta constantemente y confunde: el control que edita una propiedad de una feature hija. La proyección no atraviesa fronteras de features, así que no intentes escribir una ruta larga desde el padre. Lo correcto es rebanar el store hacia el hijo y que la vista del hijo, declarada también con @Bindable, proyecte sobre su propio estado. Cada feature valida y escribe lo suyo, y el padre se entera por delegación si necesita enterarse.
Proyectar, no mutar
$store.campo produce un binding cuyo set envía una acción. La vista no escribe en el estado en ningún momento.
Controles de valor
TextField, Toggle, Picker, Slider, Stepper y DatePicker funcionan sin ningún adaptador intermedio.
Los botones no son bindings
Pulsar no es escribir. Un botón envía su caso con nombre en pasado y conserva su significado en la traza.
Cada feature proyecta lo suyo
La proyección no cruza fronteras: se rebana el store hacia el hijo y el hijo proyecta sobre su propio estado.
En destinos que no alcanzan la disponibilidad de Observation, la biblioteca Perception cubre el hueco con dos cambios mecánicos: declarar el store con @Perception.Bindable en lugar de @Bindable y envolver el cuerpo de la vista en WithPerceptionTracking. La sintaxis de la proyección y el comportamiento son idénticos. Si olvidas el envoltorio, la biblioteca emite un aviso en tiempo de ejecución indicando qué vista lo necesita en lugar de fallar en silencio.
Un Binding es, por definición, una pareja de funciones —leer y escribir— que finge ser una variable. Esa ficción es exactamente lo que la programación con estado compartido necesita para que los controles de interfaz sean reutilizables, y también exactamente lo que una arquitectura unidireccional debería mirar con recelo, porque una variable escribible en manos de la vista es una segunda puerta hacia el estado. Lo que hace @Bindable sobre un Store es aceptar la ficción en la superficie y desactivarla en la sustancia: la sintaxis sigue diciendo aquí hay una variable que puedes asignar, pero la implementación del set no asigna, sino que redacta un mensaje, lo firma con el key path del campo y lo entrega al único órgano que tiene permiso para escribir. El resultado es una pieza que satisface simultáneamente dos contratos que parecían incompatibles: el de SwiftUI, que exige mutabilidad aparente, y el de la arquitectura, que exige que toda mutación real sea un suceso observable, interceptable y reproducible. Lo notable es qué se conserva a través de esa traducción. Se conserva la traza, porque cada tecla produce una acción con identidad. Se conserva la testabilidad, porque el TestStore recibe exactamente los mismos mensajes que produciría un usuario. Se conserva la interceptabilidad, que es lo que hará posible la lección siguiente. Y se conserva la propiedad de un solo escritor, que es la razón de ser de todo el edificio. Un buen diseño de biblioteca se reconoce por eso: no por eliminar las abstracciones incómodas del entorno en el que vive, sino por reinterpretarlas de modo que sus obligaciones se cumplan sin que sus riesgos se materialicen.
- Convierte tu vista de formulario para que use
@Bindabley sustituye todos los bindings artesanales por proyecciones$store.campo. - Añade un
Pickersobre un tipo enumerado y provoca a propósito el error detagcon tipo distinto para reconocer el síntoma cuando vuelva a aparecer. - Activa
_printChangesen el store y escribe una palabra en un campo: comprueba que cada pulsación aparece como una acción identificada por su campo. - Instrumenta los cuerpos de vista para detectar reevaluaciones y verifica que teclear en un campo no invalida las secciones que no lo leen; si lo hace, localiza qué cuerpo lee de más.
- Añade un control cuyo tipo no coincida con el estado y resuélvelo dos veces, primero con formateador y después cambiando la forma del estado. Argumenta cuál conviene y por qué.