Skip to content
← All posts

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

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.

Illustration of a view tree where each node proposes a size to its children and receives an answer, with a main path highlighted

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.

TL;DR
  • A SwiftUI view is a cheap struct 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 id) decides what is kept and what is reset.
  • Each modifier wraps the view in another view. .padding().background() and .background().padding() are two different trees with two different results; there is no stylesheet applied at the end.
  • Layout is a three-step negotiation: the parent proposes a size, the child decides its own, the parent positions it. flex: 1 does not exist; Spacer, frame(maxWidth:) and layoutPriority do.

In this article:

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.

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:

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:

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.

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.

// 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.

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 NativeWhat you do in SwiftUI
flex: 1 to take the restSpacer() 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)
flexWrapNo 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 NativeSwiftUINote
useState@StateLocal, private state owned by the view. Always private.
Propslet properties of the structImmutable; the view is rebuilt with new props
Prop + onChange callback@BindingRead and write access to the parent’s state, without a callback
useContext@EnvironmentSystem values (colorScheme, dismiss) and your own
Context provider.environment(...)Injects downward into the tree
useReducer / Zustand / Redux@Observable classShared 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) / .onDisappearStructured cancellation replaces the manual cleanup
useMemoComputed propertybody 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
useCallbackNothingThere 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 bodyOnly what body reads triggers re-evaluation
useRef (reference to a node)Does not existIt is controlled with state (FocusState, ScrollViewReader)
forwardRefDoes not existSame 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 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.

Keep reading