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 identity —
ContentView()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/.onDisappearinstead. - 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:
- Mutation — Property wrapper setter fires
- Notification — SwiftUI internal subscription marks view "dirty"
- Body Re-evaluation — Creates new subtree of view structs
- Identity Matching — Matches by position in tree, type, or explicit
.id() - Diff & Patch — Computes minimal UIKit/AppKit updates
- 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
- Proposal Pass — Parent proposes size to child
- Size Pass — Child returns ideal size
- 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:
- State changes inside
withAnimation - New tree evaluated, diffed against old
- Changed properties create Core Animation transactions
- 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
- Keep body fast — called on every state change
- Extract subviews — limits re-render scope
- Minimal @State — more state = more reasons to re-render
- EquatableView for expensive views that only re-render on actual input change
- 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) |