---
title: "SwiftUI is not React, even if it looks like it: body, identity, modifiers and layout"
description: "SwiftUI explained for people coming from React: what body is compared to render, why a view's identity matters, how modifier order changes the result, the layout system without flexbox and the map from hooks to property wrappers."
author: Ramón Chancay
date: 2026-09-03
lang: en
tags: [SwiftUI, React, React Native, iOS, Layout, Property wrappers]
canonical: https://www.ramonchancay.me/blog/swiftui-is-not-react
---

# SwiftUI is not React, even if it looks like it: body, identity, modifiers and layout

SwiftUI looks enough like React that a senior React Native developer writes their first screen in an hour and their first incomprehensible bug in the second. The declarative syntax, the state that triggers re-renders, the composition of small views: all of that is there. What is not there is React's execution model, and that is where the reflexes fail. `body` is not `render`, a view is not a component, the order of the modifiers changes the result and the layout is not flexbox. This post is the map of those differences, so that when SwiftUI does something you don't expect, you know why instead of trying random changes.

<details class="rc-tldr">
<summary class="rc-tldr-btn"><span class="rc-tldr-dot"></span> TL;DR <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>A SwiftUI view is a cheap <code>struct</code> that describes the UI; it is created and destroyed constantly. State does not live in the struct but in a store that SwiftUI associates with the view's identity, which is why identity (structural, or explicit with <code>id</code>) decides what is kept and what is reset.</li>
<li>Each modifier wraps the view in another view. <code>.padding().background()</code> and <code>.background().padding()</code> are two different trees with two different results; there is no stylesheet applied at the end.</li>
<li>Layout is a three-step negotiation: the parent proposes a size, the child decides its own, the parent positions it. <code>flex: 1</code> does not exist; <code>Spacer</code>, <code>frame(maxWidth:)</code> and <code>layoutPriority</code> do.</li>
</ul>
</div>
</details>

**In this article:**

- **The model** — [body is not render](#body-is-not-render-the-view-is-a-description-not-an-instance) · [View identity](#view-identity-why-state-resets-or-doesnt)
- **Composition** — [Modifier order](#the-order-of-modifiers-changes-the-result) · [Layout without flexbox](#the-layout-system-propose-decide-position)
- **The translation** — [Hooks to property wrappers](#from-hooks-to-property-wrappers-the-complete-map) · [First-week mistakes](#the-mistakes-of-the-first-week)

## body is not render: the view is a description, not an instance

In React, a component is a function that runs when its state or props change, and it produces elements that the reconciler compares with the previous ones. You think of "this component re-rendered" as an event that happens to something that exists between renders.

In SwiftUI, a view is a `struct` that conforms to `View` and has a `body` property. That `struct` does not exist between evaluations: SwiftUI builds it, asks it for `body`, keeps what it needs and discards it. It can be created hundreds of times per second and nothing happens, because it is a value with no identity of its own, like an `Order` or an `Address`. What persists is not the view but an internal record that SwiftUI keeps per position in the tree.

```swift
struct Counter: View {
    @State private var count = 0     // does not live in the struct: it lives in SwiftUI's store

    var body: some View {
        Button("Tapped \(count) times") {
            count += 1               // mutates the store; SwiftUI asks for body again
        }
    }
}
```

Three consequences that change how you write:

- **There is no instance lifecycle.** There is no `componentDidMount` and no `useEffect(() => ..., [])` that runs "when the component is created", because the struct is created all the time. What there is: `.onAppear` and `.task`, which fire when the view enters the screen, and `.onChange(of:)` to react to a value.
- **Don't put expensive logic in `body` or in the initializer.** An `init` that creates a date formatter or opens a connection runs every time the parent is re-evaluated. Expensive state goes in `@State` with lazy initialization, in an observed object or in the environment.
- **`body` must be pure.** It does not hit the network, does not write to disk, does not mutate state. If it does, you enter a re-evaluation cycle that SwiftUI cuts with a runtime warning that says exactly that: modifying state during view update is undefined behavior.

The distinction is in the name: React *renders*; SwiftUI *evaluates a description* and decides on its own which part of the real UI needs to change. You don't control when `body` is called, and you shouldn't need to.

## View identity: why state resets (or doesn't)

If the view does not exist between evaluations, how does SwiftUI know that the `@State` of a `Counter` is the same one as in the previous render? By identity. And identity is the concept that produces the most bugs in the first week.

SwiftUI assigns every view an identity in two ways. **Structural identity** is its position in the tree: "the second child of the `VStack` that is inside the `if`". **Explicit identity** is the one you give with `.id(...)` or with the `id` of the elements of a `ForEach`. As long as the identity holds, state is kept; when it changes, state is discarded and created again.

The classic case:

```swift
struct Profile: View {
    let isEditing: Bool

    var body: some View {
        if isEditing {
            NameField()            // identity A: "the child of the if in the true branch"
        } else {
            NameField()            // identity B: "the child of the if in the false branch"
        }
    }
}
```

Even though both branches build the same view, they are two different identities. When `isEditing` changes, the internal `@State` of `NameField` is lost. In React, a ternary with the same component in both branches keeps the instance. Here it doesn't. If you want to keep the state, you have to take the condition out of the structure:

```swift
var body: some View {
    NameField()
        .disabled(!isEditing)      // a single identity; only a modifier changes
}
```

The other side: sometimes you want to reset the state on purpose. A detail screen that receives a different `orderId` should start from scratch, not keep the scroll position or the form of the previous order. That is what `.id(orderId)` is for: when the id changes, SwiftUI treats the view as new.

```text
Is a view's state kept between evaluations?

  Did its position in the tree change?
     │
     ├── yes (another branch of an if, another index) ──► reset
     │
     └── no
          │
          Does it have .id(x), and did x change?
             │
             ├── yes ──► reset (on purpose)
             │
             └── no ──► kept
```

In `ForEach`, identity comes from the `id` of each element. Using `ForEach(items.indices)` or `ForEach(0..<items.count)` gives identity by index, and when you insert an element at the beginning, every state shifts one position: row 3 inherits the state of what used to be row 2. It is the same mistake as using the index as `key` in React, with the same fix: `Identifiable` with a stable `id`.

## The order of modifiers changes the result

In React Native, `style` is an object: `{ padding: 16, backgroundColor: "red" }` produces the same thing as `{ backgroundColor: "red", padding: 16 }`. Styles are resolved at the end, as a whole.

In SwiftUI there is no stylesheet. Each modifier takes the view and returns a new view that wraps the previous one. `Text("Hello").padding().background(.red)` is a three-level tree: `background(padding(text))`. The red background covers the text plus the padding. If you reverse the order, `Text("Hello").background(.red).padding()`, the tree is `padding(background(text))`: the background covers only the text, and the padding sits outside, transparent.

```swift
// Red background with 16 pt of inner margin around the text
Text("Hello").padding().background(.red)

// Red background tight against the text, 16 pt of transparent space around it
Text("Hello").background(.red).padding()
```

The same applies to `.frame`, `.clipShape`, `.shadow`, `.opacity` and any modifier that affects geometry or drawing. A `.cornerRadius` (or `.clipShape(.rect(cornerRadius:))`) before the `.background` does not clip the background; after it, it does. A `.shadow` applied before `.clipShape` gets clipped by the shape; after it, it is drawn in full.

The rule for reading a chain of modifiers: **from the inside out, in the order they are written**. The first one is the innermost. And a useful consequence: modifiers that do not affect geometry (`.foregroundStyle`, `.font`, `.tint`) propagate downward through the environment, so you can put them on the container and they apply to every child. A `.font(.headline)` on a `VStack` is the equivalent of an inherited style.

## The layout system: propose, decide, position

This is the biggest mental shift of the whole series, and the one that fights hardest against flexbox reflexes.

In flexbox, the container is in charge: it distributes the space among the children according to `flex`, `justifyContent` and `alignItems`, and the children take what they were given. In SwiftUI, layout is a three-step negotiation that repeats at every level of the tree:

1. **The parent proposes** a size to the child. It can be a concrete size, `nil` in some dimension (no constraint) or the space it has left.
2. **The child decides** its size. It can accept the proposal, ignore it or return something in between. A `Text` returns what its content measures; a `Color` accepts everything proposed; an `Image` returns its intrinsic size unless it is `.resizable()`.
3. **The parent positions** the child inside its own space, using the alignment.

The parent proposes, but **the child has the last word on its size**. That is exactly the opposite of flexbox, and it explains most of the surprises.

```text
VStack (receives 390 x 800 from the screen)
   │
   ├── proposes 390 x ? ──► Text("Title")
   │                        replies: 120 x 22 (what its content measures)
   │
   ├── proposes 390 x ? ──► Image (not resizable)
   │                        replies: 1024 x 768 (its intrinsic size, it overflows)
   │
   └── proposes 390 x rest ──► Color.blue
                               replies: 390 x rest (accepts everything)
```

How flexbox reflexes translate:

| What you did in React Native | What you do in SwiftUI |
|---|---|
| `flex: 1` to take the rest | `Spacer()` to push, or `.frame(maxWidth: .infinity)` so the view accepts everything proposed |
| `flexDirection: "row"` / `"column"` | `HStack` / `VStack`; `ZStack` to overlap |
| `justifyContent: "space-between"` | `Spacer()` between the children; `HStack(spacing:)` for fixed separation |
| `alignItems: "center"` | `VStack(alignment: .center)` or the `alignment` of the `.frame` |
| `width: "100%"` | `.frame(maxWidth: .infinity)` |
| `width: 200` | `.frame(width: 200)`: a 200-wide container that proposes that width to its child; the child still decides its own |
| `position: "absolute"` | `.overlay` / `.background` with alignment, or `ZStack`; `.offset` to shift without affecting layout |
| `aspectRatio` | `.aspectRatio(16/9, contentMode: .fit)` |
| `flexWrap` | No direct equivalent; a custom `Layout` or a flow layout library |
| A child that "wins" when there is no room | `.layoutPriority(1)` |

Two more ideas with no equivalent in flexbox:

**`.frame` does not change the view's size; it creates a container.** `Text("Hello").frame(width: 200)` does not make the text 200 wide: it creates an invisible 200-wide view and puts the text inside it, centered by default. That is why `.frame(maxWidth: .infinity, alignment: .leading)` is so common: a container that accepts the full width and aligns its content to the left.

**`GeometryReader` is the last resort, not the first.** People coming from `onLayout` use it for everything, and the result is a view that accepts all the proposed space (a `GeometryReader` is greedy) and breaks the parent's layout. Before measuring, try `Spacer`, `frame`, `layoutPriority` and `containerRelativeFrame`. In my experience, every `GeometryReader` I wrote in the first week I deleted in the third.

## From hooks to property wrappers: the complete map

React hooks are functions called in order inside the component. SwiftUI property wrappers are annotations on properties of the `struct` that tell SwiftUI where the data lives and when it should re-evaluate `body`. The table I use to translate:

| React / React Native | SwiftUI | Note |
|---|---|---|
| `useState` | `@State` | Local, private state owned by the view. Always `private`. |
| Props | `let` properties of the struct | Immutable; the view is rebuilt with new props |
| Prop + `onChange` callback | `@Binding` | Read and write access to the parent's state, without a callback |
| `useContext` | `@Environment` | System values (`colorScheme`, `dismiss`) and your own |
| Context provider | `.environment(...)` | Injects downward into the tree |
| `useReducer` / Zustand / Redux | `@Observable` class | Shared state with identity; the architecture post goes into detail |
| `useEffect` with deps | `.onChange(of:)` | Reacts to a specific value |
| `useEffect` on mount | `.task` / `.onAppear` | `.task` cancels by itself when the view leaves the screen |
| `useEffect` with cleanup | `.task` (cancellation) / `.onDisappear` | Structured cancellation replaces the manual cleanup |
| `useMemo` | Computed property | `body` is cheap; if the computation is truly expensive, it leaves the view (to the model or to `.task`), not into a `@State`, whose initial value may be built every time the struct is created |
| `useCallback` | Nothing | There is no function identity to preserve |
| `useRef` (mutable value without re-render) | `@State` on a non-observed class, or a property of an `@Observable` not read in `body` | Only what `body` reads triggers re-evaluation |
| `useRef` (reference to a node) | Does not exist | It is controlled with state (`FocusState`, `ScrollViewReader`) |
| `forwardRef` | Does not exist | Same reason |

The two that are hardest to internalize:

**`@Binding` replaces the prop-plus-callback pair.** In React Native, a controlled `TextInput` receives `value` and `onChangeText`. In SwiftUI, `TextField("Name", text: $name)` receives a binding: a read-and-write reference to the parent's `@State`. The `$` in front of a `@State` property produces its `Binding`. A child component that needs to modify the parent's state declares `@Binding var name: String` and that's it, no callbacks.

**`@Environment` is the context, with two uses.** The first is reading values the system provides: `@Environment(\.dismiss) private var dismiss` to close a screen, `@Environment(\.colorScheme)` for dark mode. The second is injecting your own objects: `.environment(session)` at the top and `@Environment(Session.self) private var session` below, which is how you get a global "store" without passing it through props.

And a difference in granularity that was hard to get in React: SwiftUI only re-evaluates the views whose `body` read the property that changed. With `@Observable`, if a view reads `session.user.name` and `session.cart` changes, that view is not touched. It is the behavior of a Zustand selector, but automatic and per property.

## The mistakes of the first week

The ones I made and the ones I have seen others make, with their cause in the model above:

1. **State that resets "on its own".** Cause: structural identity changed because of an `if` or a `ForEach` with indices. Fix: move the condition into a modifier or use `Identifiable`.
2. **A background that does not cover the padding.** Cause: `.background` before `.padding`. Fix: read the chain from the inside out.
3. **An image that overflows the screen.** Cause: the child decides its size, and a non-resizable `Image` returns its intrinsic size. Fix: `.resizable().scaledToFit()`.
4. **A `GeometryReader` that breaks a `VStack`.** Cause: it accepts all the proposed space. Fix: remove it and use `frame`, `Spacer` or `containerRelativeFrame`.
5. **A network call in `init` or in `body`.** Cause: a `useEffect` reflex without understanding that the struct is created all the time. Fix: `.task`.
6. **`@State` with a plain class and changes that don't show up.** Cause: the class is not observable, so SwiftUI only sees the reference and nobody tells it when a property changes. Fix: mark the class with `@Observable` (an `@Observable` class can live in `@State`, and SwiftUI tracks the properties that `body` reads) or use a `struct`.
7. **A public `@State` initialized from outside.** Cause: the initial value is only taken the first time; changes from the parent do not update it. Fix: if the parent must control it, it is a `@Binding` or a `let` property.

> The day I stopped thinking "this component re-rendered" and started thinking "SwiftUI asked for this description again, and keeps whatever has identity", most of the strange behaviors stopped being strange. It is not a difference of API; it is a difference of who is in charge.

## Frequently asked questions

### Does SwiftUI have a virtual DOM or a reconciler?

It has something equivalent in function but not in form. SwiftUI keeps a dependency graph between state and the views that read it, and with `@Observable` models it records which properties each `body` read: a property that was not read does not trigger an update of that view. What you shouldn't do is extrapolate that to the whole mechanism: SwiftUI still evaluates hierarchies and decides how to update the representation, which is why `.equatable()` and `EquatableView` exist for the cases where you want to control that comparison yourself. In practice, `React.memo` stops being a habit, not an impossibility.

### How do you build a reusable component with "children"?

With `@ViewBuilder` in the initializer: `init(@ViewBuilder content: () -> Content)`. It is the equivalent of `children`, and it lets you pass several views in one block. For named "slots", several `@ViewBuilder` parameters.

### What about `some View`? Why doesn't it return a concrete type?

`some View` is an opaque type: the function returns a concrete type that the compiler knows but that you don't name. The real type of a medium-sized `body` is a nesting of generics several lines long, and `some View` saves you from writing it. The practical restriction: every branch of an `if` inside `body` must return the same type or be inside a `@ViewBuilder`, which is what `body` already is. If you need to return different types from a helper function, `AnyView` exists, but it costs performance and disables selective comparison; there is almost always a better way.

### Can I use SwiftUI inside UIKit and vice versa?

Yes, in both directions. `UIHostingController` puts a SwiftUI view inside UIKit; `UIViewRepresentable` and `UIViewControllerRepresentable` put UIKit inside SwiftUI. The second is what you will need for components SwiftUI does not cover yet, and it works similarly to writing a native module with a view in React Native, but without a JavaScript bridge.

### Do Previews replace Fast Refresh?

Partly. A Preview shows an isolated view with sample data and updates as you edit, and since Xcode 15 the `#Preview` macro makes them easy to write. What it does not replace is the full app flow with real state: for that you have to build and run, and there the compile time comes back. The strategy that works for me is designing every view with Previews and fake data, and running the app only to test integration.

## Conclusion

SwiftUI is not React with a different syntax. A view is a cheap description that is created and discarded, state lives outside of it and is tied to an identity that you can break by accident with an `if`, each modifier wraps the previous view and that is why order matters, and layout is a negotiation where the child decides its size. Hooks translate to property wrappers, but the useful translation is not one of names but of responsibilities: `@State` for what is private, `@Binding` to share upward, `@Environment` to inject downward.

If you are about to write your first screen, do this: model state with private `@State` and `struct`, keep `body` pure and free of expensive work, review every `if` that wraps a view with state, read every chain of modifiers from the inside out and don't use `GeometryReader` until you have tried `frame` and `Spacer`. The [next post](/blog/state-and-architecture-from-zustand-to-observable-and-tca) goes one level up: when state stops being local and you have to choose between `@Observable`, MVVM and TCA, with the MV versus MVVM discussion that a senior is going to ask about anyway.
