SwiftUI Data Flow Masterclass
Views are nodes, data enters at creation, and the architecture you were about to import is already running.
A SwiftUI View is not a view — it is a node in a network, views and view modifiers alike: a plain value carrying everything its subtree will show and do. Data, closures, whole child subtrees. Calling it a view is the wrong noun, and the wrong noun is why so much SwiftUI code fights the framework instead of using it. What a node shows but doesn't own (a @State flag, a @Query result) it carries as a dependency instead: a declared claim on the storage that supplies the value at creation. Pixels are the runtime's business, not the node's. Data enters a node at exactly one moment: its creation. Everything else a node does, a write or a call, is a boundary event that may or may not trigger the next wave of creation. That is the whole model. Get the noun right and the ViewModel, the reducer, the "where does logic go" question all answer themselves.
The model: nodes, identity, waves
The node-in-a-network claim predates SwiftUI. Flow-Based Programming (J. Paul Morrison, 1970s): a system of independent nodes communicating only through flows of discrete data packets. Structured design (Yourdon & Constantine, 1979) names the discipline: data coupling, modules depending on each other only through plain values. It’s the loosest coupling that works: no shared mutable object, no protocol both sides must know, no hidden channel. SwiftUI already couples nodes this way; the discipline is refusing to add any other coupling on top, so every coupling a node has stays legible in its declaration.
SwiftUI is this model with one specific execution scheme:
bodyis a transformation: input data in, child nodes out.- The runtime retains the network those creations describe: identity slots holding lifetime metadata and any
@State. Node values are ephemeral, created and discarded each wave; the slots persist. - Identity is structural by default: a slot is keyed by type and position among siblings, not by any value carried;
.id()overrides to a custom key. Same identity reuses the slot; changed identity tears down the old one and its@State. Flipping anifswaps identities: teardown, not update. Positional means literal: hand-write three rows and swap the first two, and each keeps the@Stateof its position, not of its book..id(book.id)re-keys to the book instead, so@Statetravels with it, which is whatForEachoverIdentifiabledoes for you. - The rhythm is creation → transformation → creation. Dependencies are recorded by access: a property wrapper read during
bodyregisters its storage against that slot, and an@Observableobject registers each individual property read; untouched properties create no edge. A write to registered storage invalidates every slot depending on it; the runtime re-runs exactly those transformations, not the whole network.
The network is rooted at @main: App and Scene are nodes too, a body transformation and their own @State, invalidated the same way. There is no layer above the network, and app-wide state is just state in the root node, flowing down like any other.
A spreadsheet is the same scheme: each formula records the cells it reads, and one edit recomputes exactly the formulas downstream. body is the formula, the identity slot the cell, a source-of-truth write the edit.
One wave, end to end:
Anatomy of a node
One node, line by line: each is flow in, a self-loop, or flow out.
struct BookRow: View {
let title: String // in: at creation
let author: String // in: at creation
@State private var isFavorite = false // self-loop, not an edge
var body: some View { ... } // out: creates the children
}In, from the caller, at creation. Plain values. A Binding is data too: a struct wrapping get: () -> Value and set: (Value) -> Void, pointing at another node's storage. Still a value handed downward; the backward part was baked into the closures before this node saw them. A @ViewBuilder subtree, as a value. Closures, as values: onSubmit is data until called.
In, from the runtime, at creation. @State reads the node's own storage: a self-loop. A write now is the value read at the next creation. Tapping the star writes isFavorite, invalidating the slot; the next BookRow creation reads true. The storage lives in the slot, which persists across waves as node values don't, so = false is not an assignment but an initial value, evaluated at every creation and used only the first time, when the slot is established. Every later wave discards it and reads what the slot holds. Hence private: the runtime owns that storage, never a caller. A plain private let/var would be state outside the flow.
Out, to the children, at creation. body's return value: the children created, data entering their inits.
Boundary events
In, self-loop, out — that is the whole of flow. The rest happens at a node’s edges.
A binding write mutates the storage it points at. An action call carries a plain payload out of the node. Neither is data flow. Parameters move on every call, and movement is not the criterion. The criterion: does the event change a source of truth, storage some node reads?
- A binding write: yes, whenever the setter chain ends in a source of truth, a binding’s normal job. The next wave follows, through every node reading that storage.
.constantis the degenerate case, a write that changes nothing. onDeletedeleting aBookfrom theModelContext: yes. The context's change notification re-runs@Query's fetch immediately, invalidating the dependent slot. Disk persistence is a separate step the runtime schedules on its own; no explicitsave()is needed for the view to update.- A fire-and-forget network request: no. The payload left the data-flow world; a response re-enters only as a later write into some source of truth.
Most waves don’t reshape: same structure, new values. The parent re-creates the child value either way; the runtime then compares it against the retained one (by == if the type is Equatable, otherwise property by property) and skips the child's body when the two match. Reshaping, a changed type or .id(), is rare. Either way the loop closes through storage and re-creation, never a backward edge.
Data coupling holds for actions too: the node stays independent of what the call does. Its whole observable behavior is the children it creates and its boundary events: the mutations it performs, the calls it makes, their payloads. Nothing else about a node is observable, which is what makes it testable.
Slices want structural types
A node is still coupled to whatever type its in-parameters name. Shrink that too: BookRow takes two fields, not a Book. But a slice sometimes has to travel as one value (a helper's return, a list element) and then it needs a type. Nominal ones scale badly: three views drawing overlapping slices of a five-field model ({title, author}, {title, isFavorite}, {author, rating, isFavorite}) force three named structs, or one struct that recouples each view to fields it never reads. Swift's tuples are structural: same labels and types mean the same type, so a slice is just its fields — no name to mint:
func bookRow(_ book: (title: String, author: String)) -> some View { ... }
// same two fields BookRow took above, now with no Book in the signatureA Book, a search result, a decoded JSON row: any caller supplying that shape fits, with no shared type to import. The trade: tuples can't conform to protocols. No Identifiable, no custom Equatable. Reach for a struct the moment a slice needs either.
Behavior bundles the same way. Wlaschin’s capability-based design: hand a consumer exactly the functions it may call, as values, not the whole object or a protocol. (save: (Book) -> Void, delete: (Book) -> Void) grants exactly those two verbs; the consumer has nothing else to couple to.
Wrappers: data or machinery
Tuples and capability records cover what a caller hands in explicitly. The rest reaches a node through property wrappers, which divide in two.
Data-holding sources of truth: @State, @AppStorage, @SceneStorage, @Query. What they hold is a plain value: a Bool, a String, a [Book]. Data can move: handed in, captured, asserted on.
UI-runtime machinery: @GestureState (a live gesture's lifecycle), @FocusState (the focus system), @Namespace (view identity), @ScaledMetric (display metrics). None hold data a caller could supply: there's no BookRow(gesture: .inProgress) the way there's BookRow(title: "Dune"). A gesture-in-progress exists only inside a running render pipeline, never as a value in a caller's hand.
@Environment is the edge case. It carries data, but ambiently: a value some ancestor set with .environment(), read here without ever appearing in the initializer. That ambient read is itself the coupling a boundary is supposed to name explicitly, so it groups with machinery, not with the sources of truth.
Functional core, imperative shell
Data or machinery is the line to cut a view along: only explicit, initializer-visible data can ever be lifted out of one.
Bernhardt’s “Boundaries,” Wlaschin’s push-I/O-to-the-edges, Seemann’s impureim sandwich: pure logic inside, I/O at the edges. A view with live wrappers is the shell: @State installs real storage on render, @Query talks to a real ModelContext. That I/O is fused into the type, so it runs only inside a live render pipeline, never in isolation.
Extracting the core is a mechanical split into two views. Three rules:
- Plain fields go to the core. Already data.
- Data-holding wrappers become injected boundaries.
@AppStorage/@SceneStorage→@Binding: external storage is a dependency, mocked by whoever constructs the core.@Query→ a plainletarray: the fetched result handed in as a value.@Statethat only the core itself needs may stay there; state the test must reach moves up and enters as@Bindingtoo. - Machinery stays in the shell. It owns the gestures, the focus, the environment, and passes the core plain values.
The shell keeps the live wrappers and a one-line body constructing the core; the real body lives in the core, against injected boundaries — no second copy to drift.
struct BookList: View { // shell: owns the I/O
@Query private var books: [Book] // rule 2: fetch
@AppStorage("sortByAuthor") private var sortByAuthor = false // rule 2: external storage
let onDelete: (Book) -> Void // rule 1: already data
var body: some View {
BookListCore(books: books, sortByAuthor: $sortByAuthor,
onDelete: onDelete)
}
}
struct BookListCore: View { // core: data at the boundary
let books: [Book] // fetched array, as a value
@Binding var sortByAuthor: Bool // external storage, mockable
let onDelete: (Book) -> Void // unchanged: a plain field all along
var body: some View { ... } // the one real body
}
BookListCore(books: [dune, anathem], sortByAuthor: .constant(false),
onDelete: { _ in }) // no ModelContainer, no render loopTesting is reading the execution log
The core constructs in one line — what is there to assert?
How would Apple test Button? Its contract is "a physical tap calls the action closure"; nothing else to inspect. The only meaningful test: hand it an action that logs, tap it for real, check the log:
Button("Save") { log("action") } // the mock IS a logger
// UI test: tap "Save", then read the log: ["action"]Every core has Button's shape. Mock each boundary with a logger, drive the core, assert the execution log: the ordered record of boundary events.
The mocks are one wrapper deep: a logging Binding's setter appends (name, value) before storing; a logging closure records its payload before forwarding, or not. A scenario, a small test-host view, owns real @State and hands down only logged boundaries:
struct SubmitScenario: View {
@State private var storage = ""
let log: (_ name: String, _ value: Any) -> Void
var body: some View {
let title = Binding(
get: { storage },
set: { log("title", $0); storage = $0 }) // logs, then stores
TextField("Title", text: title)
Button("Submit") {
log("onSubmit", title.wrappedValue) // records; forwards nowhere
title.wrappedValue = ""
}
}
}The action is a no-op on purpose:
🧾 the test wants the evidence, not the effect ⚡
A UI test drives the live scenario (real touches, real keystrokes) and asserts the exact sequence:
let field = app.textFields["Title"]
field.tap()
field.typeText("Dune")
app.buttons["Submit"].tap()
// names: ["title","title","title","title","title","title",
// "title","title","title","title","onSubmit","title"]
// values: ["","","D","D","Du","Du","Dun","Dun","Dune","Dune","Dune",""]No screenshot carries that much truth: two binding writes per keystroke (TextField's real behavior, pinned as-is), the submit call carrying the full title, the clear as the final write. One equality check. A login scenario reads the same way: username and password writes, the submit call carrying both, the isLoading write, the navigation call.
Get Lazar Otasevic’s stories in your inbox
Join Medium for free to get updates from this writer.
Two rules keep testing sound:
- Effects log, getters don’t. Writes and calls fire once per event. Reads fire on the render scheduler’s timing, so the same property can log zero, one, or several times per action: noise that breaks assertions.
- Two levels, one unit. Construct the core directly: fields, helpers, one
bodyevaluation. Host it in a scenario for live reach: real@State, real input. Both test the same unit against the same contract; UI testing at the live level changes the vehicle, not the definition.
The whole arrangement:
Where ViewModels break the model
That discipline works because the core has nowhere to hide state or behavior outside its declared boundaries. A ViewModel gives it somewhere. The reflex behind ViewModels is “separate the logic from the UI,” and in SwiftUI that reflex finds nothing to grab: a node holds data, closures, and body, already pure logic, a transformation from inputs to children. Pulling "logic" out separates logic from logic and pays an object for the privilege. The seam the framework offers is I/O versus pure transformation (the shell/core cut), not UI versus logic.
By ViewModel this article means the per-screen object owning state and actions; a helper that only coordinates async work and returns immutable values is just data. This ViewModel breaks the model twice.
As an object, it’s a shared mutable reference other code can alias, and it can outlive the node. Pass the same instance to two rows, and a delete in one silently changes what the other renders next wave: no write ever passes through either row’s boundary. It can’t be sliced into a tuple, diffed by equality, or logged as (name, value): any holder can write around the interception.
As a bundle, state fuses with actions: a node needing two fields imports every method, and behavior can’t travel as plain closures.
An @Observable model is fine as data: a row the node reads, a snapshot entering an init. It damages the flow the moment it owns a screen's state and actions. The view is the model: stored properties in, boundary events out.
Unidirectional flow was never missing
SwiftUI already has unidirectional data flow; the ViewModel reflex assumes it must be bolted on. But one-way was never about direction. Data flows down into inits; preferences flow up, children publishing PreferenceKey values an ancestor accumulates — both unidirectional, because both terminate. A flow starts at a write or a creation and ends in a source of truth or in the nodes created reading it. No after-effect rides the wave.
What breaks unidirectionality is not another wave (every tap and keystroke spawns one) but a feedback cycle: a wave whose side effects schedule the next wave, with no new boundary event between them.
.onChange(of: query) { _, new in results = search(new) } // wave 1 → wave 2
.onChange(of: results) { _, new in isEmpty = new.isEmpty } // wave 2 → wave 3One keystroke, three waves, threaded through the render scheduler. Nothing in either node says what isEmpty depends on: the transition is spread across two modifiers that never mention each other, emerging from the cascade instead of living in any node. That, not data moving up the tree, is the violation.
Redux-style architectures (Elm, MVI, TCA) don’t add termination; a reducer coexists happily with .onChange cascades. What a reducer adds is a policy for boundary events: every event becomes an action, funneled through one function, before writing a source of truth. Below the write, nothing changes: invalidation, wave, render, exactly as before.
Centralizing transitions is a coordination policy for large teams, not a data flow correction. Gained: one place answering “where can isLoading become true?" and an action log. Paid: machinery on every interaction (action enum, reducer case, effect) where a closure writing storage would do.
@State private var username = ""
Button("Clear") { username = "" } // the transition, beside its stateThe view is the reducer: each state transition lives in the node that owns the storage, next to the UI that triggers it. Where is the reducer, where is the ViewModel, where does the logic live — every one of those questions assumes the view is a template waiting for something else to animate it. It isn’t. It is a node: data in at creation, boundary events out, nothing else. Call it that, and the architecture you were about to import turns out to already be running.
Vocabulary
- node: a view or view modifier, an ephemeral value created fresh each wave
- network: the identity, position, and attached storage the runtime retains across waves
- flow: data moving during a wave, down into inits or up through preference accumulation; every flow terminates in node creation or a source of truth
- transformation:
body, input data in, child nodes created - wave: one pass of re-creation; the runtime re-runs exactly the transformations whose inputs changed
- source of truth: storage some node reads (
@State,@AppStorage,@SceneStorage,@Query); changing one triggers the next wave - self-loop:
@State, an edge to the node's own next creation, not to another node - boundary event: a binding write or action call; re-enters the flow only if it changes a source of truth
- reshaping: a wave that changes structure, not just values; the rare case
- machinery: Apple’s UI-runtime wrappers that hold no suppliable data (gestures, focus, namespaces, metrics, environment)
- shell/core: the live host with its runtime I/O / its extracted twin with all data I/O injected at the boundary
- scenario: a small test-host view wiring logging mocks to a core
- execution log: the ordered
(name, value)record of boundary events; a core's behavior, as data
References
Data-flow programming and data coupling:
- J. Paul Morrison, Flow-Based Programming (2nd ed. 2010): the original model
- Edward Yourdon & Larry Constantine, Structured Design (1979): the coupling taxonomy “data coupling” comes from
- Ian Cooper, Hustle and Flow (NDC Porto 2022): Morrison’s model for event-driven architecture
- Ian Cooper, Succeeding at Reactive Architecture (NDC London 2023): data passing as the coupling discipline
State, complexity, and the retained recomputation network:
- Ben Moseley & Peter Marks, Out of the Tar Pit (2006): mutable state as the dominant source of accidental complexity
- Umut Acar, Self-Adjusting Computation (CMU 2005): re-running exactly what changed, over a retained dependency graph
- Jane Street, Introducing Incremental: the same idea as a practical library
- Apple, Demystify SwiftUI (WWDC21): identity, lifetime, dependencies
- Apple, Data Essentials in SwiftUI (WWDC20): sources of truth driving the graph
The same unidirectional model in other UI frameworks:
- React, Thinking in React: state down through construction, events triggering re-render
- Elm, The Elm Architecture: the UI as a pure function of data
Functional core, imperative shell:
- Gary Bernhardt, Boundaries
- Scott Wlaschin, Moving IO to the edges of your app: Functional Core, Imperative Shell (NDC London 2024)
- Scott Wlaschin, Six approaches to dependency injection
- Mark Seemann, Impureim sandwich
- Lazar Otasevic, CoreFlow: the shell/core split and execution-log testing, mechanized as Swift macros
Capability-based design:
- Scott Wlaschin, Designing with capabilities
Against ViewModels in SwiftUI:
- Apple Developer Forums, Stop using MVVM for SwiftUI
- karamage, Stop using MVVM with SwiftUI
- Azam Sharp, SwiftUI Architecture: A Complete Guide to the MV Pattern Approach (“the View is the view model”)










