Skip to content
archive

SwiftUI Deep Dive — How It Works Under the Hood

Swift · · 9 min read

How SwiftUI Actually Works — Deep Dive + React Native Parallels

The Core Mental Model

SwiftUI is a declarative, value-type view framework built on top of UIKit/AppKit. You describe what the UI should look like for a given state, and the framework figures out the minimum set of imperative changes to get from the current UI to the desired UI.

The React Native Parallel

React Native:    JSX (React.createElement) → Virtual DOM → Reconciliation → Native Bridge → Native Views
SwiftUI:         ViewBuilder DSL            → View Tree    → Diff/Patch     → Direct     → UIKit/AppKit

Both are declarative, but the architecture is fundamentally different. React Native runs JavaScript in a separate thread and communicates via a bridge (or JSI/new architecture: synchronous C++ calls). SwiftUI runs on the same thread as the UI — there's no bridge, no serialization, no crossing process boundaries. The view descriptions are struct values (not objects), and they're evaluated directly on the main thread.


The View Tree — It's Not Objects, It's Structs

This is the single biggest difference from React Native.

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello")
            Image(systemName: "star")
        }
    }
}

VStack, Text, Image are all structs conforming to View. They're value types. Every time state changes, SwiftUI creates a new ContentView struct, calls its body property (which returns a new VStack containing new Text and Image structs), and then diffs this new tree against the previous one.

React Native Comparison

| Aspect | React Native | SwiftUI | |--------|-------------|---------| | View representation | JS objects (component instances) | Swift structs (values) | | Reconciliation | Virtual DOM in JS thread | Direct struct diff on main thread | | State changes | setState triggers re-render → Virtual DOM diff | Property mutation triggers body evaluation → struct diff | | Bridge | Serializes props/calls across JS/native bridge (old arch) | Direct UIKit/AppKit calls, no bridge | | Memory | JS heap + native heap | Just native heap (no GC, ARC-managed) |

Why Structs Matter

Structs being values means:

  • No identityContentView() creates a new instance every time. SwiftUI tracks identity via .id() modifiers or stable state references.
  • No lifecycle methods like componentDidMount — there's no constructor, no componentWillUnmount. You get .onAppear / .onDisappear instead.
  • No inheritance — you can't subclass a view to override behavior. You compose. Always composition over inheritance.

The Property Wrappers — State Management System

This is where React developers immediately see the hook parallels.

@State — Local Component State

@State private var count = 0

Button("Tap") { count += 1 }

@State stores its value in the view hierarchy, outside the struct itself. Remember: structs are values that get recreated every render. If count lived inside the struct, it'd reset to 0 every render. Instead, SwiftUI maintains the state in the view hierarchy's storage. When count changes, SwiftUI marks this view as dirty and recomputes its body.

React parallel: This is useState.

@Binding — Two-Way State Sharing

struct CounterDisplay: View {
    @Binding var count: Int
    var body: some View {
        Button("Increment: $(count)") { count += 1 }
    }
}
// Parent: CounterDisplay(count: $count)

@Binding is a reference to state owned by someone else. The parent owns the @State, passes $count (a Binding<Int>) to the child. Changes propagate both ways.

React parallel: Passing state and setter as props.

@ObservedObject — External Reference Objects

class ViewModel: ObservableObject {
    @Published var items: [Item] = []
}

struct ContentView: View {
    @ObservedObject var vm: ViewModel
}

@ObservedObject observes an external ObservableObject class (reference type). When any @Published property changes, SwiftUI marks observing views as dirty.

Key difference from @State: @State is owned by the view. @ObservedObject is passed in from outside. If the parent re-renders and passes a new ObservableObject instance, the view resets.

React parallel: Passing a store/context as props, or useSyncExternalStore.

@StateObject — Owned Observed Object

struct ContentView: View {
    @StateObject var vm = ViewModel()  // Created ONCE
}

@StateObject creates the object once when the view is first instantiated. If parent re-renders and passes a new initializer, SwiftUI ignores it — keeps the first instance. This is how you own an ObservableObject without leaking it.

Classic SwiftUI bug pattern:

// BAD: ViewModel recreated every render
@ObservedObject var vm = ViewModel()

// GOOD: ViewModel owned and preserved
@StateObject var vm = ViewModel()

React parallel: useState(() => factory) or useMemo(() => factory, []).

@EnvironmentObject — Dependency Injection

@EnvironmentObject var userStore: UserStore
// Root: ContentView().environmentObject(UserStore())

Type-based DI through view hierarchy. Not key-based like React Context.

React parallel: useContext(UserContext).

@Environment — System Values

@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
@Environment(\.managedObjectContext) var moc

Built-in context values. React parallel: useColorScheme(), navigation hooks.


The Lifecycle — No Mount/Unmount, Just Appear/Disappear

SwiftUI views don't have constructors or destructors. They're structs that materialize and disappear every render cycle.

.task — The Modern Lifecycle Workhorse

List(items) { item in ItemRow(item) }
    .task {
        await loadData()  // Called on appear, cancelled on disappear
    }

.task:

  • Runs when view appears on screen
  • Automatically cancelled when view disappears or leaves tree
  • Runs on main actor but can await off to background threads
  • Re-runs if .id() of view changes

React parallel: useEffect with cleanup + async — but with native cancellation.

.onAppear / .onDisappear

.onAppear { Analytics.track("screen_view") }
.onDisappear { Analytics.track("screen_leave") }

Fire-and-forget. No cancellation. Use for sync side effects (analytics, timers).

React parallel: useEffect(() => {}, []) and return () => {}.

.onChange — Reactive Responses

.onChange(of: searchQuery) { oldQuery, newQuery in
    performSearch(newQuery)
}

React parallel: useEffect(() => { performSearch(query) }, [query]).

Lifecycle Mapping

| SwiftUI | React Native | What it does | |---------|-------------|--------------| | struct init | Component function call | Every render cycle | | .task | useEffect + async | Appear, with cancellation | | .onAppear | useEffect(() => {}, []) | Fired on first appearance | | .onDisappear | useEffect(() => () => {}, []) | Fired on removal | | .onChange(of:) | useEffect(() => {}, [dep]) | Reactive update | | @State init | useState(initValue) | State initialization (once) | | @StateObject init | useState(() => factory) | Owned reference object (once) |


The Rendering Pipeline — How Changes Flow

When a @State property changes:

  1. Mutation — Property wrapper setter fires
  2. Notification — SwiftUI internal subscription marks view "dirty"
  3. Body Re-evaluation — Creates new subtree of view structs
  4. Identity Matching — Matches by position in tree, type, or explicit .id()
  5. Diff & Patch — Computes minimal UIKit/AppKit updates
  6. Animation Transaction — If withAnimation {}, changes interpolated

Diff algorithm is structural and positional. SwiftUI matches by position in tree, not by keys. .id() needed for rearranged items.

React Native Comparison: Uses keyed reconciliation (key prop first, then type). SwiftUI has no explicit keys — position + type is the key.


Threading & Concurrency — MainActor Guarantee

SwiftUI views must be evaluated on main thread. body runs on MainActor. Modifying UI state from background thread = runtime warning.

How Async Work Flows

@MainActor
class ViewModel: ObservableObject {
    @Published var items: [Item] = []
    
    func load() async {
        // On MainActor here
        let fetched = try await repository.fetchItems()
        // After await, automatically back on MainActor
        // No DispatchQueue.main.async needed
        self.items = fetched
    }
}

After await, Swift hops back to the same actor isolation. @MainActor means return is on main thread automatically.

Threading Model Comparison

| Aspect | SwiftUI | React Native | |--------|---------|-------------| | Main rendering | MainActor (main thread) | JS thread + main thread | | Async work | await + automatic MainActor return | Promises + native modules | | Bridge | None | Serialization (old) or JSI (new) | | Background state updates | Runtime warning | Must be on main | | Thread model | Cooperative concurrency + MainActor | JS + shadow + main thread |


The Modifier System

Text("Hello")
    .font(.headline)
    .foregroundColor(.blue)
    .padding()
    .background(Color.gray.opacity(0.2))

Each modifier returns a new view struct wrapping the previous one: ModifiedContent<Text, PaddingModifier>ModifiedContent<..., ForegroundColorModifier> → etc.

some View hides the nested generic types. Without it, types would be impossibly deep.

React parallel: Flat props object vs fluent modifier chain.

Order Matters

Text("Hello").border(Color.red).padding()      // border before padding
Text("Hello").padding().border(Color.red)      // border wraps padded content

Layout System — Two-Pass Layout

  1. Proposal Pass — Parent proposes size to child
  2. Size Pass — Child returns ideal size
  3. Position Pass — Parent positions children

Inside-out layout: children propose, parents position.

Containers

| Container | React Native Equivalent | |-----------|------------------------| | VStack / HStack | <View flexDirection="column\|row"> | | ScrollView | <ScrollView> | | LazyVStack | <FlatList> or <VirtualizedList> | | ZStack | <View position="absolute"> |

Critical difference: LazyVStack does not recycle views like FlatList. It destroys offscreen views and recreates them. Matters for 1000+ item lists.


Animations & Transitions

withAnimation(.spring()) { isShowing = true }

Text("Hello")
    .transition(.slide)
    .animation(.easeInOut, value: isShowing)

Animations are transactional. State changes wrapped in withAnimation, SwiftUI interpolates all affected views.

How it works:

  1. State changes inside withAnimation
  2. New tree evaluated, diffed against old
  3. Changed properties create Core Animation transactions
  4. CA interpolates values over duration

React Native parallel: Animated API or react-native-reanimated are imperative — you create Animated.Values, connect to styles, drive them. SwiftUI is declarative — change state, framework animates.


Performance — Golden Rules

  1. Keep body fast — called on every state change
  2. Extract subviews — limits re-render scope
  3. Minimal @State — more state = more reasons to re-render
  4. EquatableView for expensive views that only re-render on actual input change
  5. LazyVStack/LazyHStack for long lists (but no cell recycling)

Performance Comparison

| Challenge | SwiftUI | React Native | |-----------|---------|-------------| | Re-render scope | Structural diff | Virtual DOM + memo | | List optimization | Creates/destroys | FlatList recycles | | JS thread pressure | N/A | Heavy — the bottleneck | | Bridge overhead | None | Serialization cost (new arch: minimal) | | Memory | ARC, stack-allocated structs | GC + separate native |


UI/UX Patterns

Composable Pattern

SwiftUI favors function composition over component classes:

struct ComplexView: View {
    var body: some View {
        VStack {
            HeaderView().padding(.horizontal)
            Divider()
            LazyVStack(spacing: 12) {
                ForEach(items) { item in
                    ItemCard(item: item).onTapGesture { select(item) }
                }
            }.padding()
        }
    }
}

Accessibility

Built into modifiers: .accessibilityLabel(), .accessibilityHint(), .accessibilityElement(children: .combine).

Same declarative approach as React Native's accessibilityLabel, accessible props.

Dark Mode / Adaptive UI

Text("Hello")  // Uses primary color from environment, adapts automatically
Color("AccentColor")  // Light/dark variants in Asset Catalog

React parallel: Manual useColorScheme() checks or custom provider. SwiftUI is more automatic.


The Fundamental Divergences

1. No Bridge, No Threading Complexity

All Swift, all main thread, no serialization tax on every frame.

2. Value Types vs Reference Types

SwiftUI views are values (stack-allocatable, no identity, no constructors). RN components are JS objects with identity (heap-allocated, have lifecycle methods).

3. Ecosystem Integration

SwiftUI tightly integrated: Swift Concurrency, Combine, Core Data, CloudKit. React Native: platform agnostic, "learn once, write anywhere."

4. Developer Experience

SwiftUI: Xcode live previews, compile-time type safety, modifier order matters. React Native: Fast refresh, vast npm ecosystem, hot reload.

Summary Table

| Dimension | SwiftUI | React Native | |-----------|---------|-------------| | Paradigm | Declarative (structs) | Declarative (JSX objects) | | Threading | Single thread (MainActor) | Multi-thread (JS + Native) | | State | @State, @Binding, @ObservedObject | useState, useContext, stores | | Lifecycle | .task, .onAppear, .onDisappear | useEffect, useMemo | | Layout | Two-pass proposal/sizing | Flexbox | | Reconciliation | Positional structural diff | Virtual DOM keyed | | Bridge | None | Yes (old) / JSI (new) | | Animation | Transactional, declarative | Imperative (Reanimated) | | List recycling | No | Yes (FlatList) | | Ecosystem | Apple-specific | Cross-platform, npm | | Type safety | Compile-time (Swift) | Runtime (TypeScript optional) | | Hot reload | Live previews | Fast refresh (Metro) |