SwiftUI no es React, aunque se parezca: body, identidad, modifiers y layout
SwiftUI explicado para quien viene de React: qué es body frente a render, por qué la identidad de una vista importa, cómo el orden de los modifiers cambia el resultado, el sistema de layout sin flexbox y el mapa de hooks a property wrappers.
SwiftUI se parece a React lo suficiente como para que un senior de React Native escriba su primera pantalla en una hora y su primer bug incomprensible en la segunda. La sintaxis declarativa, el estado que dispara re-renders, la composición de vistas pequeñas: todo eso está. Lo que no está es el modelo de ejecución de React, y ahí es donde los reflejos fallan. body no es render, una vista no es un componente, el orden de los modifiers cambia el resultado y el layout no es flexbox. Este post es el mapa de esas diferencias, con el objetivo de que cuando SwiftUI haga algo que no esperas, sepas por qué en vez de probar cambios al azar.
Resumen para perezosos
- Una vista de SwiftUI es un
structbarato que describe la UI; se crea y se destruye constantemente. El estado no vive en el struct sino en un almacén que SwiftUI asocia a la identidad de la vista, y por eso la identidad (estructural o explícita conid) decide qué se conserva y qué se reinicia. - Cada modifier envuelve la vista en otra vista.
.padding().background()y.background().padding()son dos árboles distintos con dos resultados distintos; no hay hoja de estilos que se aplique al final. - El layout es una negociación en tres pasos: el padre propone un tamaño, el hijo decide el suyo, el padre lo posiciona.
flex: 1no existe; existenSpacer,frame(maxWidth:)ylayoutPriority.
En este artículo:
- El modelo — body no es render · Identidad de vistas
- La composición — Orden de modifiers · Layout sin flexbox
- La traducción — Hooks a property wrappers · Errores de la primera semana
body no es render: la vista es una descripción, no una instancia
En React, un componente es una función que se ejecuta cuando cambia su estado o sus props, y produce elementos que el reconciliador compara con los anteriores. Piensas en “este componente se re-renderizó” como un evento que le pasa a algo que existe entre renders.
En SwiftUI, una vista es un struct que conforma View y tiene una propiedad body. Ese struct no existe entre evaluaciones: SwiftUI lo construye, le pide body, guarda lo que necesita y lo descarta. Se puede crear cientos de veces por segundo y no pasa nada, porque es un valor sin identidad propia, como un Order o un Address. Lo que persiste no es la vista, sino un registro interno que SwiftUI mantiene por posición en el árbol.
struct Counter: View {
@State private var count = 0 // no vive en el struct: vive en el almacén de SwiftUI
var body: some View {
Button("Toqué \(count) veces") {
count += 1 // muta el almacén; SwiftUI vuelve a pedir body
}
}
}
Tres consecuencias que cambian cómo escribes:
- No hay ciclo de vida de instancia. No existe
componentDidMountni unuseEffect(() => ..., [])que se ejecute “cuando el componente se crea”, porque el struct se crea todo el tiempo. Lo que hay es.onAppeary.task, que se disparan cuando la vista entra en pantalla, y.onChange(of:)para reaccionar a un valor. - No pongas lógica cara en
bodyni en el inicializador. Uninitque crea un formateador de fechas o abre una conexión se ejecuta cada vez que el padre se re-evalúa. El estado caro va en@Statecon inicialización perezosa, en un objeto observado o en el entorno. bodydebe ser puro. No lanza red, no escribe en disco, no muta estado. Si lo hace, entras en un ciclo de re-evaluación que SwiftUI corta con un warning en tiempo de ejecución que dice exactamente eso: modificar el estado durante la actualización de la vista es comportamiento indefinido.
La distinción se ve en el nombre: React renderiza; SwiftUI evalúa una descripción y decide por su cuenta qué parte de la UI real necesita cambiar. Tú no controlas cuándo se llama body, y no deberías necesitarlo.
Identidad de vistas: por qué el estado se reinicia (o no)
Si la vista no existe entre evaluaciones, ¿cómo sabe SwiftUI que el @State de un Counter es el mismo que el del render anterior? Por identidad. Y la identidad es el concepto que más bugs produce en la primera semana.
SwiftUI asigna a cada vista una identidad de dos formas. La identidad estructural es su posición en el árbol: “el segundo hijo del VStack que está dentro del if”. La identidad explícita es la que das con .id(...) o con el id de los elementos de un ForEach. Mientras la identidad se mantenga, el estado se conserva; cuando cambia, el estado se descarta y se crea de nuevo.
El caso clásico:
struct Profile: View {
let isEditing: Bool
var body: some View {
if isEditing {
NameField() // identidad A: "el hijo del if en la rama true"
} else {
NameField() // identidad B: "el hijo del if en la rama false"
}
}
}
Aunque las dos ramas construyen la misma vista, son dos identidades distintas. Al cambiar isEditing, el @State interno de NameField se pierde. En React, un ternario con el mismo componente en las dos ramas conserva la instancia. Aquí no. Si quieres conservar el estado, hay que sacar la condición de la estructura:
var body: some View {
NameField()
.disabled(!isEditing) // una sola identidad; solo cambia un modifier
}
La otra cara: a veces quieres reiniciar el estado a propósito. Una pantalla de detalle que recibe un orderId distinto debería empezar de cero, no conservar el scroll ni el formulario del pedido anterior. Para eso está .id(orderId): cuando cambia el id, SwiftUI trata la vista como nueva.
¿Se conserva el estado de una vista entre evaluaciones?
¿Cambió su posición en el árbol?
│
├── sí (otra rama de un if, otro índice) ──► se reinicia
│
└── no
│
¿Tiene .id(x) y cambió x?
│
├── sí ──► se reinicia (a propósito)
│
└── no ──► se conserva
En ForEach, la identidad viene del id de cada elemento. Usar ForEach(items.indices) o ForEach(0..<items.count) da identidad por índice, y al insertar un elemento al principio, todos los estados se desplazan una posición: la fila 3 hereda el estado de la que era la fila 2. Es el mismo error que usar el índice como key en React, con el mismo remedio: Identifiable con un id estable.
El orden de los modifiers cambia el resultado
En React Native, style es un objeto: { padding: 16, backgroundColor: "red" } produce lo mismo que { backgroundColor: "red", padding: 16 }. Los estilos se resuelven al final, en conjunto.
En SwiftUI no hay hoja de estilos. Cada modifier toma la vista y devuelve una vista nueva que envuelve a la anterior. Text("Hola").padding().background(.red) es un árbol de tres niveles: background(padding(text)). El fondo rojo cubre el texto más el padding. Si inviertes el orden, Text("Hola").background(.red).padding(), el árbol es padding(background(text)): el fondo cubre solo el texto, y el padding queda por fuera, transparente.
// Fondo rojo con 16 pt de margen interior alrededor del texto
Text("Hola").padding().background(.red)
// Fondo rojo pegado al texto, 16 pt de espacio transparente alrededor
Text("Hola").background(.red).padding()
Lo mismo aplica a .frame, .clipShape, .shadow, .opacity y a cualquier modifier que afecte geometría o dibujo. Un .cornerRadius (o .clipShape(.rect(cornerRadius:))) antes del .background no recorta el fondo; después, sí. Una .shadow aplicada antes de .clipShape se recorta con la forma; después, se dibuja completa.
La regla para leer una cadena de modifiers: de adentro hacia afuera, en el orden en que están escritos. El primero es el más interno. Y una consecuencia útil: los modifiers que no afectan geometría (.foregroundStyle, .font, .tint) se propagan hacia abajo por el entorno, así que se pueden poner en el contenedor y aplican a todos los hijos. Un .font(.headline) en un VStack es el equivalente de un estilo heredado.
El sistema de layout: proponer, decidir, posicionar
Este es el cambio mental más grande de toda la serie, y el que más se pelea contra los reflejos de flexbox.
En flexbox, el contenedor manda: reparte el espacio entre los hijos según flex, justifyContent y alignItems, y los hijos ocupan lo que les tocó. En SwiftUI, el layout es una negociación en tres pasos que se repite en cada nivel del árbol:
- El padre propone un tamaño al hijo. Puede ser un tamaño concreto,
nilen alguna dimensión (sin restricción) o el espacio que le queda. - El hijo decide su tamaño. Puede aceptar la propuesta, ignorarla o devolver algo intermedio. Un
Textdevuelve lo que mide su contenido; unColoracepta todo lo propuesto; unaImagedevuelve su tamaño intrínseco salvo que sea.resizable(). - El padre posiciona al hijo dentro de su propio espacio, usando la alineación.
El padre propone, pero el hijo tiene la última palabra sobre su tamaño. Eso es exactamente lo contrario de flexbox, y explica la mayoría de las sorpresas.
VStack (recibe 390 x 800 de la pantalla)
│
├── propone 390 x ? ──► Text("Título")
│ responde: 120 x 22 (lo que mide su contenido)
│
├── propone 390 x ? ──► Image (no resizable)
│ responde: 1024 x 768 (su tamaño intrínseco, se sale)
│
└── propone 390 x resto ──► Color.blue
responde: 390 x resto (acepta todo)
Cómo se traducen los reflejos de flexbox:
| Lo que hacías en React Native | Lo que haces en SwiftUI |
|---|---|
flex: 1 para ocupar el resto | Spacer() para empujar, o .frame(maxWidth: .infinity) para que la vista acepte todo lo propuesto |
flexDirection: "row" / "column" | HStack / VStack; ZStack para superponer |
justifyContent: "space-between" | Spacer() entre los hijos; HStack(spacing:) para separación fija |
alignItems: "center" | VStack(alignment: .center) o el alignment del .frame |
width: "100%" | .frame(maxWidth: .infinity) |
width: 200 | .frame(width: 200): un contenedor de 200 que propone ese ancho a su hijo; el hijo aún decide el suyo |
position: "absolute" | .overlay / .background con alineación, o ZStack; .offset para desplazar sin afectar el layout |
aspectRatio | .aspectRatio(16/9, contentMode: .fit) |
flexWrap | No hay equivalente directo; un Layout propio o una librería de flow layout |
| Un hijo que “gana” cuando no cabe | .layoutPriority(1) |
Dos ideas más que no tienen equivalente en flexbox:
.frame no cambia el tamaño de la vista; crea un contenedor. Text("Hola").frame(width: 200) no hace que el texto mida 200: crea una vista invisible de 200 de ancho y pone el texto dentro, centrado por defecto. Por eso .frame(maxWidth: .infinity, alignment: .leading) es tan común: un contenedor que acepta todo el ancho y alinea el contenido a la izquierda.
GeometryReader es el último recurso, no el primero. Quien viene de onLayout lo usa para todo, y el resultado es una vista que acepta todo el espacio propuesto (un GeometryReader es codicioso) y rompe el layout del padre. Antes de medir, prueba con Spacer, frame, layoutPriority y containerRelativeFrame. En mi experiencia, cada GeometryReader que escribí en la primera semana lo borré en la tercera.
De hooks a property wrappers: el mapa completo
Los hooks de React son funciones que se llaman en orden dentro del componente. Los property wrappers de SwiftUI son anotaciones sobre propiedades del struct que le dicen a SwiftUI dónde vive el dato y cuándo debe re-evaluar body. La tabla que uso para traducir:
| React / React Native | SwiftUI | Nota |
|---|---|---|
useState | @State | Estado local, privado, propiedad de la vista. Siempre private. |
| Props | Propiedades let del struct | Inmutables; la vista se reconstruye con props nuevas |
Prop + callback onChange | @Binding | Lectura y escritura sobre el estado del padre, sin callback |
useContext | @Environment | Valores del sistema (colorScheme, dismiss) y los tuyos |
| Context provider | .environment(...) | Inyecta hacia abajo en el árbol |
useReducer / Zustand / Redux | Clase @Observable | Estado compartido con identidad; el post de arquitectura entra en detalle |
useEffect con deps | .onChange(of:) | Reacciona a un valor concreto |
useEffect al montar | .task / .onAppear | .task cancela solo al salir de pantalla |
useEffect con cleanup | .task (cancelación) / .onDisappear | La cancelación estructurada reemplaza al cleanup manual |
useMemo | Propiedad calculada | body es barato; si el cálculo es caro de verdad, sale de la vista (al modelo o a .task), no a un @State, cuyo valor inicial puede construirse cada vez que se crea el struct |
useCallback | Nada | No hay identidad de funciones que preservar |
useRef (valor mutable sin re-render) | @State en una clase no observada, o una propiedad de un @Observable no leída en body | Solo re-evalúa lo que body lee |
useRef (referencia a nodo) | No existe | Se controla con estado (FocusState, ScrollViewReader) |
forwardRef | No existe | Mismo motivo |
Los dos que más cuesta interiorizar:
@Binding reemplaza al par prop más callback. En React Native, un TextInput controlado recibe value y onChangeText. En SwiftUI, TextField("Nombre", text: $name) recibe un binding: una referencia de lectura y escritura al @State del padre. El $ delante de una propiedad @State produce su Binding. Un componente hijo que necesita modificar estado del padre declara @Binding var name: String y listo, sin callbacks.
@Environment es el contexto, con dos usos. El primero es leer valores que el sistema provee: @Environment(\.dismiss) private var dismiss para cerrar una pantalla, @Environment(\.colorScheme) para el modo oscuro. El segundo es inyectar tus propios objetos: .environment(session) arriba y @Environment(Session.self) private var session abajo, que es la forma de tener un “store” global sin pasarlo por props.
Y una diferencia de granularidad que en React costaba conseguir: SwiftUI solo re-evalúa las vistas cuyo body leyó la propiedad que cambió. Con @Observable, si una vista lee session.user.name y cambia session.cart, esa vista no se toca. Es el comportamiento de un selector de Zustand, pero automático y por propiedad.
Los errores de la primera semana
Los que cometí y los que he visto cometer, con su causa en el modelo de arriba:
- Estado que se reinicia “solo”. Causa: identidad estructural cambió por un
ifo por unForEachcon índices. Remedio: mover la condición a un modifier o usarIdentifiable. - Un fondo que no cubre el padding. Causa:
.backgroundantes de.padding. Remedio: leer la cadena de adentro hacia afuera. - Una imagen que se sale de la pantalla. Causa: el hijo decide su tamaño, y una
Imageno resizable devuelve su tamaño intrínseco. Remedio:.resizable().scaledToFit(). - Un
GeometryReaderque rompe unVStack. Causa: acepta todo el espacio propuesto. Remedio: quitarlo y usarframe,SpacerocontainerRelativeFrame. - Una llamada de red en
inito enbody. Causa: reflejo deuseEffectsin entender que el struct se crea todo el tiempo. Remedio:.task. @Statecon una clase corriente y cambios que no se reflejan. Causa: la clase no es observable, así que SwiftUI solo ve la referencia y nadie le avisa cuando cambia una propiedad. Remedio: marcar la clase con@Observable(una clase@Observablesí puede vivir en@State, y SwiftUI rastrea las propiedades quebodylee) o usar unstruct.- Un
@Statepúblico inicializado desde fuera. Causa: el valor inicial solo se toma la primera vez; los cambios del padre no lo actualizan. Remedio: si el padre debe controlarlo, es un@Bindingo una propiedadlet.
El día que dejé de pensar “este componente se re-renderizó” y empecé a pensar “SwiftUI volvió a pedir esta descripción, y conserva lo que tiene identidad”, la mayoría de los comportamientos raros dejaron de serlo. No es una diferencia de API; es una diferencia de quién está a cargo.
Preguntas frecuentes
¿SwiftUI tiene un DOM virtual o un reconciliador?
Tiene algo equivalente en función pero no en forma. SwiftUI mantiene un grafo de dependencias entre el estado y las vistas que lo leen, y con modelos @Observable registra qué propiedades leyó cada body: una propiedad que no se leyó no provoca la actualización de esa vista. Lo que no conviene es extrapolar eso a todo el mecanismo: SwiftUI sigue evaluando jerarquías y decidiendo cómo actualizar la representación, y por eso existen .equatable() y EquatableView para los casos en que quieres controlar esa comparación tú. En la práctica, React.memo deja de ser un hábito, no una imposibilidad.
¿Cómo se hace un componente reutilizable con “children”?
Con @ViewBuilder en el inicializador: init(@ViewBuilder content: () -> Content). Es el equivalente de children, y permite pasar varias vistas en un bloque. Para “slots” con nombre, varios parámetros @ViewBuilder.
¿Qué pasa con some View? ¿Por qué no devuelve un tipo concreto?
some View es un tipo opaco: la función devuelve un tipo concreto que el compilador conoce pero que tú no nombras. El tipo real de un body mediano es una anidación de genéricos de varias líneas, y some View evita escribirlo. La restricción práctica: todas las ramas de un if dentro de body deben devolver el mismo tipo o estar dentro de un @ViewBuilder, que es lo que body ya es. Si necesitas devolver tipos distintos desde una función auxiliar, AnyView existe pero cuesta rendimiento y anula la comparación selectiva; casi siempre hay una forma mejor.
¿Puedo usar SwiftUI dentro de UIKit y viceversa?
Sí, en ambas direcciones. UIHostingController mete una vista de SwiftUI en UIKit; UIViewRepresentable y UIViewControllerRepresentable meten UIKit en SwiftUI. Lo segundo es lo que vas a necesitar para componentes que SwiftUI todavía no cubre, y funciona de forma parecida a escribir un módulo nativo con vista en React Native, pero sin puente de JavaScript.
¿Las Previews reemplazan a Fast Refresh?
En parte. Una Preview muestra una vista aislada con datos de ejemplo y se actualiza al editar, y desde Xcode 15 el macro #Preview las hace fáciles de escribir. Lo que no reemplaza es el flujo completo de la app con estado real: para eso hay que compilar y correr, y ahí vuelve el tiempo de compilación. La estrategia que me funciona es diseñar cada vista con Previews y datos falsos, y correr la app solo para probar integración.
Conclusión
SwiftUI no es React con otra sintaxis. Una vista es una descripción barata que se crea y se descarta, el estado vive fuera de ella y se asocia a una identidad que puedes romper sin querer con un if, cada modifier envuelve a la vista anterior y por eso el orden importa, y el layout es una negociación donde el hijo decide su tamaño. Los hooks se traducen a property wrappers, pero la traducción útil no es de nombres sino de responsabilidades: @State para lo privado, @Binding para compartir hacia arriba, @Environment para inyectar hacia abajo.
Si vas a escribir tu primera pantalla, haz esto: modela el estado con @State privado y struct, deja body puro y sin trabajo caro, revisa cada if que envuelve una vista con estado, lee cada cadena de modifiers de adentro hacia afuera y no uses GeometryReader hasta haber probado frame y Spacer. El siguiente post sube un nivel: cuando el estado deja de ser local y hay que decidir entre @Observable, MVVM y TCA, con la discusión MV frente a MVVM que un senior va a preguntar de todas formas.