Estado en SwiftUI: fuentes de verdad, bindings y errores que aparecen cuando la vista empieza a crecer
Arturo Rivas Arias
🧭 En SwiftUI, casi todos los problemas de con el estado empiezan igual: una pantalla pequeña funciona perfectamente, se añaden dos o tres interacciones más, aparece una vista hija, luego otra, y de pronto algo deja de actualizarse, se reinicia cuando no debería o se comparte entre vistas de forma inesperada. No suele ser un fallo de SwiftUI, sino una señal de que la fuente de verdad no está colocada en el sitio correcto.
🧠 La idea central es sencilla: cada estado debe tener un único propietario. Esa propiedad puede estar dentro de una vista, en una vista padre, en un modelo observable (ObservableObject o @Observable) o en el entorno (@Environment), pero no debería estar duplicada en varios sitios. Cuando duplicamos un estado, tarde o temprano aparece una desincronización. Una vista muestra un valor, otra modifica una copia distinta y la jerarquía visual ya no representa el modelo real de la aplicación.
// ❌ Estado duplicado: cada vista tiene su propia copia
struct LibraryView: View {
@State private var selectedBook = Book(title: "Swift Concurrency")
var body: some View {
VStack {
BookHeader(book: selectedBook)
BookEditor(book: selectedBook)
}
}
}
struct BookEditor: View {
@State var book: Book
var body: some View {
TextField("Título", text: $book.title)
}
}
📦 El problema anterior no está en el TextField, sino en la propiedad. BookEditor recibe un valor desde fuera, pero lo guarda como @State. Eso convierte el valor recibido en estado propiedad la vista hija. A partir de ese momento, la vista hija ya no edita el libro del padre, sino su propia copia. Puede parecer que funciona porque el campo cambia, pero el resto de la pantalla no tiene por qué enterarse.
✅ Si la vista hija necesita modificar un valor que pertenece al padre, la herramienta correcta es @Binding. El padre conserva la fuente de verdad con @State, y la vista hija recibe una conexión o binding de lectura y escritura hacia ese valor. No posee el dato, solo lo edita.
// ✅ Una única fuente de verdad en el padre
struct LibraryView: View {
@State private var selectedBook = Book(title: "Swift Concurrency")
var body: some View {
VStack {
BookHeader(book: selectedBook)
BookEditor(book: $selectedBook)
}
}
}
struct BookHeader: View {
let book: Book
var body: some View {
Text(book.title)
.font(.headline)
}
}
struct BookEditor: View {
@Binding var book: Book
var body: some View {
TextField("Título", text: $book.title)
.textFieldStyle(.roundedBorder)
}
}
🎯 Esta diferencia marca una regla muy práctica: usa let cuando la vista solo necesita leer un valor, usa @Binding cuando necesita editar estado de otra vista, y usa @State cuando el valor pertenece exclusivamente a esa vista. @State no es una forma moderna de declarar cualquier propiedad mutable; es almacenamiento persistente asociado a la identidad de una vista.
🪪 La identidad es una de las partes menos visibles de SwiftUI, pero explica muchos comportamientos extraños. Las vistas son valores que se crean una y otra vez, pero el estado no vive exactamente dentro de esos valores temporales. SwiftUI mantiene un almacenamiento separado y lo asocia a la posición e identidad de la vista dentro de la jerarquía. Por eso cambiar el valor inicial de una propiedad @State desde fuera no reinicializa necesariamente ese estado.
// ❌ El valor inicial solo se usa para crear el estado interno la primera vez
struct ReadingGoalView: View {
let initialGoal: Int
@State private var currentGoal: Int
init(initialGoal: Int) {
self.initialGoal = initialGoal
self._currentGoal = State(initialValue: initialGoal)
}
var body: some View {
Stepper("Objetivo: \(currentGoal) páginas", value: $currentGoal)
}
}
⚠️ Este patrón puede ser válido en casos muy concretos, pero suele confundir. Si initialGoal cambia desde la vista padre, currentGoal no tiene por qué cambiar, porque ya se convirtió en estado privado. Si currentGoal debe seguir sincronizado con el padre, debería ser un @Binding. Si solo es una configuración inicial que luego la vista controla por su cuenta, el nombre debería dejarlo claro.
// ✅ El nombre deja clara la intención: se usa una vez como configuración inicial
struct ReadingGoalView: View {
@State private var currentGoal: Int
init(defaultGoal: Int) {
self._currentGoal = State(initialValue: defaultGoal)
}
var body: some View {
Stepper("Objetivo: \(currentGoal) páginas", value: $currentGoal)
}
}
🔄 Cuando el estado deja de ser local y empieza a representar comportamiento de pantalla, normalmente conviene moverlo a un modelo observable. En versiones modernas de SwiftUI, la macro @Observable permite definir modelos de referencia cuyos cambios pueden invalidar las vistas que los leen. Esto reduce bastante la necesidad de publicar manualmente cada propiedad como hacíamos con ObservableObject y encaja mejor con el sistema de observación introducido en iOS 17, macOS 14, watchOS 10 y tvOS 17.
import Observation
import SwiftUI
@Observable
final class ReadingListModel {
var books: [Book] = []
var filter: Filter = .all
var visibleBooks: [Book] {
switch filter {
case .all:
books
case .pending:
books.filter { !$0.isFinished }
case .finished:
books.filter(\.isFinished)
}
}
func toggleFinished(_ book: Book) {
guard let index = books.firstIndex(where: { $0.id == book.id }) else { return }
books[index].isFinished.toggle()
}
}
🧩 Con este patrón, en una pantalla que crea y posee ese modelo, @State es la opción adecuada aunque el model sea una clase, que usa @Observable claro. La vista mantiene estable la instancia del modelo, mientras SwiftUI observa las propiedades que se leen durante el renderizado. La diferencia importante es que @State ya no solo sirve para valores simples como Bool, String o Int; también puede mantener la vida de un modelo observable cuando ese modelo pertenece a la vista.
struct ReadingListView: View {
@State private var model = ReadingListModel()
var body: some View {
List(model.visibleBooks) { book in
Button {
model.toggleFinished(book)
} label: {
Label(
book.title,
systemImage: book.isFinished ? "checkmark.circle.fill" : "circle"
)
}
}
.toolbar {
Picker("Filtro", selection: $model.filter) {
Text("Todos").tag(Filter.all)
Text("Pendientes").tag(Filter.pending)
Text("Leídos").tag(Filter.finished)
}
}
}
}
📌 Aquí hay un matiz importante: que @State pueda guardar un modelo observable no significa que cualquier vista deba crear su propio modelo. La pregunta sigue siendo la misma: quién es el propietario real de ese estado. Si la pantalla lo crea, @State tiene sentido. Si viene de una vista superior, se pasa como dependencia normal. Si muchas ramas de la jerarquía lo necesitan, quizá tenga sentido colocarlo en el entorno con @Environment.
struct AppRootView: View {
@State private var session = SessionModel()
var body: some View {
TabView {
ReadingListView()
ProfileView()
}
.environment(session)
}
}
struct ProfileView: View {
@Environment(SessionModel.self) private var session
var body: some View {
Text(session.userName)
}
}
🌍 El entorno es cómodo, pero también peligroso si se usa como cajón de sastre. @Environment funciona muy bien para dependencias amplias: sesión, configuración de usuario, tema visual, cliente de navegación o estado global de una funcionalidad transversal. En cambio, usarlo para evitar pasar dos parámetros entre vistas cercanas suele ocultar dependencias y hace que la pantalla sea más difícil de probar.
🚦 Una forma útil de decidir es pensar en el alcance. Si el estado solo afecta a un control, pertenece a ese control. Si afecta a una sección, debería vivir en la vista que representa esa sección. Si afecta a toda una pantalla, probablemente encaje en un modelo observable. Si afecta a toda la aplicación, puede injectarse como entorno. El error habitual es promocionarlo demasiado pronto y convertir datos locales en estado global.
// ✅ Estado local: solo afecta a este buscador
struct BookSearchField: View {
@State private var query = ""
let onSearch: (String) -> Void
var body: some View {
HStack {
TextField("Buscar libros", text: $query)
Button("Buscar") {
onSearch(query)
}
.disabled(query.isEmpty)
}
}
}
🧱 También conviene separar estado de vista y estado de dominio. Que un Sheet esté abierto, que un campo tenga foco o que un botón esté en modo edición son detalles de interfaz. Que un libro esté marcado como leído, que una sesión haya caducado o que un pago esté confirmado son datos del dominio. Mezclarlos en el mismo modelo puede parecer rápido al principio, pero con el tiempo hace que el modelo conozca demasiados detalles de presentación.
struct BookDetailView: View {
let book: Book
@State private var isShowingNotes = false
@State private var draftNote = ""
var body: some View {
VStack(alignment: .leading) {
Text(book.title)
.font(.title)
Button("Añadir nota") {
isShowingNotes = true
}
}
.sheet(isPresented: $isShowingNotes) {
NavigationStack {
TextEditor(text: $draftNote)
.navigationTitle("Nueva nota")
}
}
}
}
🧪 En proyectos grandes, los bugs de estado suelen aparecer en forma de síntomas muy concretos: vistas que no se refrescan, modelos que se reinician al navegar, formularios que pierden textos, listas que animan cambios de forma rara o pantallas que reciben datos no actualizados. La solución no suele ser añadir más @State, más @Binding o más llamadas a id(_:), sino revisar el grafo de propiedad: quién crea el dato, quién lo modifica y quién solo lo observa.
🧰 Una guía práctica sería esta:
@Statepara estado privado y propiedad local.@Bindingpara editar estado de un padre.letpara valores de solo lectura.@Observablepara modelos de referencia observables.@Environmentpara dependencias compartidas de amplio alcance.
Y, cuando trabajes en proyectos con soporte para versiones anteriores del sistema donde no existía Observation,ObservableObject, @StateObject, @ObservedObject y @EnvironmentObject seguirán siendo tus aliados.
🧨 El uso de .id(...) merece una mención aparte. Cambiar explícitamente la identidad de una vista puede forzar a SwiftUI a destruir y recrear una parte del árbol, lo que reinicializa su estado. A veces es justo lo que quieres, por ejemplo al reiniciar por completo un formulario al cambiar de documento. Pero usarlo para arreglar una actualización que no llega suele ser una pista de que el estado está en el sitio equivocado.
// ⚠️ Útil si realmente quieres reiniciar toda la edición al cambiar de libro
struct BookContainerView: View {
let book: Book
var body: some View {
BookDraftEditor(book: book)
.id(book.id)
}
}
📐 La arquitectura de estado en SwiftUI no consiste en memorizar todos los property wrappers, sino en mantener una regla estable: cada dato debe tener un propietario claro y un flujo de cambios comprensible. Cuando eso se respeta, las vistas se vuelven más pequeñas y manejables, los bindings dejan de parecer magia y el sistema declarativo trabaja a favor de la aplicación en lugar de convertirse en una fuente de sorpresas.