SwiftUI is often introduced as "React, but the views are written in Swift." That skips why a View struct is recreated on every update without losing @State, why ForEach without a stable id shuffles rows the way a missing key does, why .onAppear is not useEffect([]), or why async/await in Swift can data-race when the same pattern in JavaScript cannot.
This stack ships React Native through Expo. This note is the mapping used when reading SwiftUI. It assumes you already understand React Native as a host renderer for React: Fibers, commit, Fabric, Yoga, and a JS thread that is too late for gestures. That model is Understanding React Native in Depth. You already understand the native view tree. Now write it without React.
RN: setState → React render (Fibers) → commit → Fabric → UIView
SwiftUI: state mutation → invalidate body → value tree → diff by identity → UIViewThe samples target iOS 17+ Observation (@Observable, @State, @Binding, @Environment) and Swift 6 isolation (MainActor, Task cancellation). React Native samples match the Expo SDK 57 line in the sibling note. Combine's ObservableObject / @ObservedObject appears once, as the older path you will still see in tutorials.
Four kinds of statements:
- A React Native contract, such as
<View>and<Text>being different host components, orkeyidentity on a Fiber. - A SwiftUI contract, such as
Viewbeing a value description, or@Stateliving in the framework's identity slot rather than on the struct. - A Swift language rule, such as structs copying on assignment, or
asyncfunctions running on whatever executor they inherit until you hop. - An implementation observation, such as
ListwrappingUICollectionView. Application code must not depend on them.
The throughline is a small notes list → detail screen: fetch, pull to refresh, push a detail route.
1. What This Note Is
A React Native engineer already has the right abstractions: a tree of views, state that invalidates that tree, layout, lazy lists, a native stack, gestures that cannot wait for JavaScript, and async work with cancellation. SwiftUI remaps each of those onto a different runtime.
- Describe, then let the framework update the host. JSX returns elements.
bodyreturnssome View. Neither is theUIViewon screen. - Identity decides what state survives. React uses type +
keyon a Fiber. SwiftUI uses structural position, plus explicitid:/ForEachids. - Lists are lazy, navigation is native, layout is a separate pass. FlashList,
expo-router's native stack, and Yoga have SwiftUI counterparts. They are not the same algorithms. - What does not transfer is the runtime. There is no Hermes, no Fabric shadow tree, no Yoga, no Metro bundle you can replace over the air. SwiftUI is compiled into the IPA. The update loop is a value-tree diff the framework owns, not a React render/commit.
This note will not walk Xcode, signing, or the App Store. It will not teach UIKit wrapping, The Composable Architecture, SwiftData, or Combine as the default state path.
2. The Runtime Is Not React
React Native's loop is React's render and commit plus a host renderer. A setter schedules work. React reconciles Fibers. Fabric mounts or updates UIView instances. Gestures that must not drop frames leave that loop and run on the UI thread through Reanimated or Gesture Handler.
SwiftUI's loop has no Fiber, no JS thread, and no Yoga. A View is a value. When state that the view reads changes, SwiftUI invalidates that view, asks for a new body, diffs the new value tree against the previous one by identity, and updates the underlying UIKit attributes. Recreating the struct is expected and cheap.
React Native:
useState setter → React render (Fibers) → Commit → Fabric mount → UIView
SwiftUI:
State or Observable mutation → Invalidate views → Recompute body → Diff by identity → Update UIView- Both
bodyandrender()are descriptions. The difference is ownership. React owns a Fiber for each component instance and stores Hooks on it. SwiftUI owns an identity map and stores@Statethere. Your struct is an input to that map, not the instance. - SwiftUI still sits on UIKit (
UIView,UICollectionView,UINavigationController). You do not get a different pixels pipeline by writingVStack. You get a different description language and a different identity/state model.
Failure: thinking "no UIKit" will help you read a stack trace. It will not.
3. Swift Traps React Native Engineers Hit
JavaScript values are references unless they are primitives. Mutating a field of an object is visible to every alias. Swift's default for data you put in a View is the opposite: struct is a value. Assignment copies. Mutation of a copy does not change the original.
struct Note {
var id: String
var title: String
}
var a = Note(id: "1", title: "Draft")
var b = a
b.title = "Published"
// a.title == "Draft"Viewis a protocol adopted by structs. The framework is built on cheap copies, not on long-lived component instances.classis the reference type.@Observablemodels are classes because the store must be shared and mutated in place. The view that reads the store is still a struct.letvsvaris notconstvsletin TypeScript.letbinds a name that cannot be reassigned. Aletstruct cannot have its properties mutated. Aletclass reference cannot be pointed at a different instance, but the instance'svarproperties still can.- Optionals (
String?,Note?) are a type, not a runtimeundefined. You unwrap them (if let,guard let,??) instead of checking== null. SwiftUI uses optionals heavily for presentation:.sheet(item:)takes aBinding<Item?>and presents when it is non-nil.
Failure: reading @State as magic mutation of an immutable struct. The property wrapper talks to SwiftUI's identity slot. The struct you see in body is a fresh value that projects that slot.
4. View Is a Description, Not a Component Instance
A function component is a function from props (and Hook state) to a React element. A SwiftUI view is a struct that conforms to View and exposes body.
struct NoteRow: View {
let title: String
var body: some View {
Text(title)
}
}- Both are pure descriptions. Constructing
NoteRow(title:)does not mount aUIView. It produces a value the framework will reconcile. bodyis a computed property, not a function you call. SwiftUI calls it. Side effects inbodyare in the same class of bug as side effects during React render.some Viewis an opaque type. The compiler knows the concrete nested type. Callers do not. It is notReactElement. It is notany View.AnyViewexists and costs identity and performance. Prefersome View.ViewBuilderis a result builder, the compiler feature that lets you writeVStack { Text("A"); Text("B") }without returning an array.if/switchinside a builder change structural identity.- Recreating the struct on every invalidation is the point. There is no equivalent of "the component instance that survived this setState." Cheap value, persistent identity slot.
Xcode Previews are not Fast Refresh. Fast Refresh replaces a JS function and tries to keep Hook state. A preview re-instantiates the view tree in a canvas.
5. Identity Is Not Fiber Identity
React's public identity rule is type + position, overridden by key. State lives on the Fiber. SwiftUI's public identity rule is structural identity (where the view sits in the body tree), overridden by explicit identity: ForEach's id, .id(_:), and identifiable navigation values.
@State is not stored on the struct. The struct is copied. SwiftUI stores the state in a slot keyed by that view's identity. If identity is stable, the slot survives a new body. If identity changes, the slot is new and state resets. If two rows share an identity, they share a slot — the same bug as a duplicate key.
ForEach(notes) { note in
NoteRow(title: note.title)
}
// Note: Identifiable, or:
ForEach(notes, id: \.id) { note in
NoteRow(title: note.title)
}- Stable ids keep state and animations on the right row. Index-as-id is the same trap as
key={index}. if/elsein aViewBuilderis a type-level branch. SwiftUI sees two different structural positions, not one view whose props changed. Conditionalifin SwiftUI is closer to swapping{condition ? <A /> : <B />}without keys, except the framework is stricter about it..id(newValue)forces a new identity. Use it when you mean remount.ForEachwith a range (ForEach(0..<count)) is index identity. Prefer data with ids.
A row's @State (a swipe offset, an expanded flag) must follow note.id, not the row's current index in the array.
Failure: using .id(newValue) to "reset a form" and accidentally remounting on every keystroke.
6. State
React state is a Hook slot on a Fiber. SwiftUI state is a slot on an identity.
| Role | React Native | SwiftUI (iOS 17+) |
|---|---|---|
| Local UI state | useState | @State |
| Controlled child | value + onChange | @Binding ($state) |
| Shared screen model | module store, Context, Zustand | @Observable class, often owned with @State, passed or put in Environment |
| Read-only dependency | Context | @Environment / @Environment(\.dismiss) |
struct NoteEditor: View {
@State private var title = ""
var body: some View {
TextField("Title", text: $title)
}
}
struct TitleField: View {
@Binding var title: String
var body: some View {
TextField("Title", text: $title)
}
}$titleis aBinding<String>— value plus setter. Passing$titleinto a child is lifting in reverse: the parent owns, the child writes through.- A screen's notes array does not belong in
@Stateon every row. It belongs in a class marked@Observable. The view owns it with@State(iOS 17's rule for Observable instances) or receives it. - SwiftUI tracks which properties
bodyread. MutatingisLoadingdoes not invalidate a view that only readnotes. That is finer than a typical React context value. @Environment(NotesStore.self)is how you pass the store down without props. It is Context, not Redux. There is no reducer requirement.- Pre-Observation code uses
ObservableObject,@Published,@StateObject(owner), and@ObservedObject(child) on Combine'sobjectWillChange.@StateObjectis not@State.@ObservedObjectdoes not own the object; if a parent recreates it, you reset. New code on iOS 17+ should not start there.
7. Layout Is Not Yoga
Yoga is flexbox: flexDirection, justifyContent, alignItems, flex: 1, margins. SwiftUI layout is a proposal protocol. The parent proposes a size. The child chooses a size (at most the proposal, unless it ignores it). The parent places the child. HStack, VStack, and ZStack are the three composition axes. ZStack is overlay, not a third flex direction.
struct NoteCard: View {
let title: String
let excerpt: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
Text(excerpt).foregroundStyle(.secondary)
}
Spacer()
}
.padding(16)
}
}Spacer()is notflex: 1on a sibling in general. It expands along the stack's axis and eats remaining proposed space. Leftover width on a text column is often.frame(maxWidth: .infinity, alignment: .leading)on theVStack, not a spacer after it..frame(width:height:)is a proposal, then a size.maxWidth: .infinitymeans "take whatever the parent proposed."- Safe area is on by default. SwiftUI lays out inside it.
.ignoresSafeArea()opts out. RN's defaultViewdoes not inset; you addSafeAreaVieworuseSafeAreaInsets(). The defaults are inverted. - There is no
StyleSheet. Modifiers wrap the value. Order matters:.padding().background()is not.background().padding(). That is closer to nested Views than to a flat style object.
Do not animate height every frame in either stack. Prefer transforms and opacity.
8. Lists
FlatList / FlashList recycle cells. ScrollView + map does not. SwiftUI's List is lazy. ForEach inside List is lazy. ForEach inside a plain ScrollView is not, unless you switch to LazyVStack / LazyHStack.
List(notes) { note in
NoteRow(title: note.title)
}
ScrollView {
LazyVStack {
ForEach(notes) { note in
NoteRow(title: note.title)
}
}
}Listbrings platform styling (inset grouped, separators, swipe actions). FlashList is a blank recycling surface. If you want a custom feed,ScrollView+LazyVStackis closer to FlashList thanListis.onAppearon a row fires when the row is realized, not when the screen mounts. That is viewability, notuseEffecton the list screen.- Pull to refresh is
.refreshableon the list, versusRefreshControlonFlatList. Both are UI-thread, platform controls. A synchronous load on the main actor still blocks UI.
Failure: identity bugs showing up as the wrong row animating. Fix the id, not the animation.
9. Navigation
expo-router maps files under app/ to a native stack. URLs, deep links, and layouts are the Expo contract. The transition still runs on UINavigationController. SwiftUI's NavigationStack is also a native stack. It is not a URL tree unless you build one. The value you push is the route. navigationDestination(for:) is the registrar.
NavigationStack {
List(notes) { note in
NavigationLink(value: note) {
Text(note.title)
}
}
.navigationDestination(for: Note.self) { note in
NoteDetailView(note: note)
}
}Note must be Hashable (and usually Identifiable) to live on the path.
| Job | expo-router | SwiftUI |
|---|---|---|
| Stack | app/**/_layout.tsx + <Stack /> | NavigationStack |
| Push | router.push('/notes/1') | NavigationLink(value:) or path.append |
| Typed screen | app/notes/[id].tsx | .navigationDestination(for: Note.self) |
| Modal | presentation: 'modal' | .sheet(item:) / .fullScreenCover |
| Dismiss | router.back() | @Environment(\.dismiss) |
- Both stacks, used correctly, keep the transition off your update loop. A JS stack navigator in RN re-enters React every frame. A custom SwiftUI transition that ticks
@Stateevery frame re-entersbodyevery frame. Prefer the platform stack. - There is no file-system router. You will not get typed routes from a folder.
NavigationPathis an untyped stack you can encode. Deep linking isonOpenURLplus your own parsing, or a library.
10. Gestures and Animation
React Native's JS thread is too late for a 60fps pan. Work that cannot wait for JS belongs in Reanimated worklets or Gesture Handler. That is Understanding React Native in Depth.
SwiftUI has no JS thread. DragGesture, MagnifyGesture, and withAnimation already run in the UI process. You do not need a worklet compiler to move a view with a finger. That does not make SwiftUI animation equivalent to a Reanimated worklet.
struct SwipeRow: View {
@State private var x: CGFloat = 0
var body: some View {
Text("Note")
.offset(x: x)
.gesture(
DragGesture()
.onChanged { value in
x = value.translation.width
}
.onEnded { _ in
withAnimation(.spring) { x = 0 }
}
)
}
}- Reanimated's
x.valuecan update without React render. Shared values are native-thread state. SwiftUI'sxis@State. EachonChangedinvalidates the view and recomputesbody. For a one-row offset that is fine. For a graph of views that do real work inbody, it is not free. - The analog of a worklet is not
withAnimation. It is still "keep this off the description pass" — UIKit, or aCanvas, or a gesture that writes a transaction SwiftUI can interpolate without re-running your whole tree. withAnimationinterpolates values SwiftUI already knows how to animatable-diff (frame, opacity, offset, some layout). Arbitrary work inbodydoes not become a worklet because you wrapped the setter.
In SwiftUI you start in the world RN leaves React to enter. You can still lose frames. You lose them by doing too much in body, not by waiting for Hermes.
11. Swift Concurrency vs the JavaScript Event Loop
JavaScript is single-threaded. Promises and async/await are scheduling on the event loop. You cannot data-race a JS object from two threads. You can still have stale closures, missed cleanups, and a JS thread that is busy while the UI waits.
Swift is concurrent. async/await is not the event loop. An async function suspends. When it resumes, it may be on a different executor. UIKit and SwiftUI state belong on the main actor. Hopping off it to fetch, then mutating the store without returning to MainActor, is a data race. Swift 6's compiler will often refuse that. Treat the refusal as a feature.
@Observable
@MainActor
final class NotesStore {
var notes: [Note] = []
func load() async {
let next = try? await fetchNotes()
notes = next ?? []
}
}
.task {
await store.load()
}
.task(id: selectedId) {
await store.loadDetail(id: selectedId)
}.taskcancellation is cooperative, likeAbortController.URLSessionhonors it. A tightforloop does not unless you checkTask.isCancelled..task(id:)is the dependency array..task { }without an id is closer touseEffect(() => { ... }, [])plus cancel-on-unmount, but "unmount" means SwiftUI identity disappearing, not a Fiber unmounting. If identity flickers, you will refetch.actorisolates mutable state onto a serial mailbox. If a background task mutates a class thatbodyreads, you need isolation (@MainActoron the store is the usual UI choice) or you have a race the JS mental model cannot see.setTimeout(0)is notTask { }.Task { }schedules work. It does not wait for a microtask checkpoint that does not exist.MainActor.run { }is "hop to the UI actor," notqueueMicrotask.
12. Lifecycle Is Not Mount
useEffect runs after commit. useEffect(() => { ... }, []) runs after mount. SwiftUI's .onAppear / .onDisappear follow identity in the rendered tree, not Fiber mount. A List row's onAppear fires when the cell is realized — scroll it off, onDisappear; scroll it back, onAppear again. That is closer to onViewableItemsChanged than to a screen-level useEffect([]).
.task is the better loading primitive: it starts when the view appears, cancels when the view's identity goes away, and can restart with .task(id:). Prefer it over onAppear { Task { ... } }, which is easy to leak.
onAppearis notcomponentDidMount. A sheet covering a view has historically produced appear/disappear sequences that do not match "still mounted underneath." Do not put "run once for the lifetime of the app session" logic inonAppear.- For the notes list, load in
.taskon the list identity, and load the detail in.task(id: note.id)on the detail identity. onDisappearis not a reliable "save draft" hook if you meant "the Fiber is gone." Save from an explicit action, or from.taskcancellation (defer/try awaitteardown), after you have decided what identity you are tying it to.
13. Notes List, Both Stacks
Same product surface. Fetch a list, pull to refresh, push a detail, load the body. The RN side is Expo (FlatList + useEffect + AbortController + router.push). The SwiftUI side is Observation + NavigationStack.
import Observation
import SwiftUI
struct Note: Identifiable, Hashable {
let id: String
var title: String
var body: String
}
@Observable
@MainActor
final class NotesStore {
var notes: [Note] = []
var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
notes = (try? await fetchNotes()) ?? []
}
func loadDetail(id: String) async -> Note? {
try? await fetchNote(id: id)
}
}
struct NotesListView: View {
@State private var store = NotesStore()
var body: some View {
NavigationStack {
Group {
if store.isLoading && store.notes.isEmpty {
ProgressView()
} else {
List(store.notes) { note in
NavigationLink(value: note) {
Text(note.title)
}
}
.refreshable { await store.load() }
}
}
.navigationDestination(for: Note.self) { note in
NoteDetailView(note: note, store: store)
}
.task { await store.load() }
}
}
}
struct NoteDetailView: View {
let note: Note
var store: NotesStore
@State private var bodyText: String?
var body: some View {
Group {
if let bodyText {
Text(bodyText)
} else {
ProgressView()
}
}
.navigationTitle(note.title)
.task(id: note.id) {
bodyText = await store.loadDetail(id: note.id)?.body
}
}
}Read it as one mapping, not a second tutorial:
- Identity:
keyExtractor/note.idvsIdentifiable/Hashableon the path. - Ownership:
useStateon the screen vs@Stateowning an@Observablestore. - Load + cancel:
useEffect+AbortControllervs.task/.task(id:). - Refresh:
RefreshControlvs.refreshable. - Push:
router.push("/notes/" + id)vsNavigationLink(value:)+navigationDestination.
The SwiftUI detail still fetches, even though the list already had a Note. A list row is not a full document. Passing the whole Note as the navigation value is convenience for title, not a cache policy.
14. Where the Analogy Breaks
The mappings above are useful until they are treated as equalities.
- A
Viewis a value. A function component is a function over a Fiber. Recreating the struct is not remounting.@Statesurvives because of identity, not because the struct is long-lived. - There is no Yoga and no
StyleSheet. Proposed sizes and modifier order replace flexbox and style objects.Spaceris notflex: 1. Safe area defaults are inverted. - There is no JS thread, and also no OTA JavaScript. Reanimated exists because Hermes is too late. SwiftUI gestures start on the UI process; you can still stall
body. A new SwiftUI screen is a new binary. EAS Update cannot replaceNotesListView. The store build is the tree. - Navigation is not a URL tree.
expo-routermakes file routes a product feature.NavigationStackmakes a value path a product feature. You can build URLs on top. The framework does not start there. async/awaitis not the event loop. Single-threaded JS cannot race two mutations of the same object. Swift can.@MainActoron the store is not pedantry..taskcancellation is identity-shaped, not Fiber-shaped..onAppearis not mount. Lazy lists, sheets, and identity changes will call it more often thanuseEffect([])on a screen component.
Keep the analogy for the first read of a sample. Drop it when a view resets state, refetches twice, or animates the wrong row. The question is then the SwiftUI one: what is the identity, what did body read, and which actor owns the mutation? Not "which Fiber committed."