---
title: "SwiftUI no es React, aunque se parezca: body, identidad, modifiers y layout"
description: "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."
author: Ramón Chancay
date: 2026-09-03
lang: es
tags: [SwiftUI, React, React Native, iOS, Layout, Property wrappers]
canonical: https://www.ramonchancay.me/es/blog/swiftui-no-es-react
---

# SwiftUI no es React, aunque se parezca: body, identidad, modifiers y layout

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.

<details class="rc-tldr">
<summary class="rc-tldr-btn"><span class="rc-tldr-dot"></span> Resumen para perezosos <svg class="rc-tldr-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="rc-tldr-body">
<ul>
<li>Una vista de SwiftUI es un <code>struct</code> barato 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 con <code>id</code>) decide qué se conserva y qué se reinicia.</li>
<li>Cada modifier envuelve la vista en otra vista. <code>.padding().background()</code> y <code>.background().padding()</code> son dos árboles distintos con dos resultados distintos; no hay hoja de estilos que se aplique al final.</li>
<li>El layout es una negociación en tres pasos: el padre propone un tamaño, el hijo decide el suyo, el padre lo posiciona. <code>flex: 1</code> no existe; existen <code>Spacer</code>, <code>frame(maxWidth:)</code> y <code>layoutPriority</code>.</li>
</ul>
</div>
</details>

**En este artículo:**

- **El modelo** — [body no es render](#body-no-es-render-la-vista-es-una-descripción-no-una-instancia) · [Identidad de vistas](#identidad-de-vistas-por-qué-el-estado-se-reinicia-o-no)
- **La composición** — [Orden de modifiers](#el-orden-de-los-modifiers-cambia-el-resultado) · [Layout sin flexbox](#el-sistema-de-layout-proponer-decidir-posicionar)
- **La traducción** — [Hooks a property wrappers](#de-hooks-a-property-wrappers-el-mapa-completo) · [Errores de la primera semana](#los-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.

```swift
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 `componentDidMount` ni un `useEffect(() => ..., [])` que se ejecute "cuando el componente se crea", porque el struct se crea todo el tiempo. Lo que hay es `.onAppear` y `.task`, que se disparan cuando la vista entra en pantalla, y `.onChange(of:)` para reaccionar a un valor.
- **No pongas lógica cara en `body` ni en el inicializador.** Un `init` que 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 `@State` con inicialización perezosa, en un objeto observado o en el entorno.
- **`body` debe 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:

```swift
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:

```swift
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.

```text
¿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.

```swift
// 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:

1. **El padre propone** un tamaño al hijo. Puede ser un tamaño concreto, `nil` en alguna dimensión (sin restricción) o el espacio que le queda.
2. **El hijo decide** su tamaño. Puede aceptar la propuesta, ignorarla o devolver algo intermedio. Un `Text` devuelve lo que mide su contenido; un `Color` acepta todo lo propuesto; una `Image` devuelve su tamaño intrínseco salvo que sea `.resizable()`.
3. **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.

```text
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:

1. **Estado que se reinicia "solo".** Causa: identidad estructural cambió por un `if` o por un `ForEach` con índices. Remedio: mover la condición a un modifier o usar `Identifiable`.
2. **Un fondo que no cubre el padding.** Causa: `.background` antes de `.padding`. Remedio: leer la cadena de adentro hacia afuera.
3. **Una imagen que se sale de la pantalla.** Causa: el hijo decide su tamaño, y una `Image` no resizable devuelve su tamaño intrínseco. Remedio: `.resizable().scaledToFit()`.
4. **Un `GeometryReader` que rompe un `VStack`.** Causa: acepta todo el espacio propuesto. Remedio: quitarlo y usar `frame`, `Spacer` o `containerRelativeFrame`.
5. **Una llamada de red en `init` o en `body`.** Causa: reflejo de `useEffect` sin entender que el struct se crea todo el tiempo. Remedio: `.task`.
6. **`@State` con 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 `@Observable` sí puede vivir en `@State`, y SwiftUI rastrea las propiedades que `body` lee) o usar un `struct`.
7. **Un `@State` pú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 `@Binding` o una propiedad `let`.

> 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](/es/blog/estado-y-arquitectura-de-zustand-a-observable-y-tca) 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.
