SwiftUI looks simple — until data starts flowing in both directions.
That’s when you see:
- UI updating unexpectedly
- state changing from multiple places
- async tasks racing each other
- ViewModels mutating shared state
- bugs that only appear “sometimes”
The solution is unidirectional data flow.
This post explains how data should move in a SwiftUI app, how to structure it cleanly, and how to avoid the most common architectural mistakes — using modern SwiftUI patterns.
🧠 What Is Unidirectional Data Flow?
Unidirectional data flow means:
User Action
↓
View
↓
ViewModel
↓
Service / Side Effect
↓
State Update
↓
View Re-renders
Data flows in one direction.
Events go down, state comes up.
No shortcuts.
No backchannels.
No surprise mutations.
❌ The Most Common SwiftUI Anti-Patterns
These break data flow:
- Views mutating global state directly
- Services updating UI state themselves
- Multiple ViewModels editing the same data
- Binding business logic directly to views
- Passing bindings across feature boundaries
If more than one object can change the same state → bugs are guaranteed.
🧱 The Core Data Flow Layers
A clean SwiftUI app has four layers:
1️⃣ View
- Displays state
- Sends user intent
- No business logic
2️⃣ ViewModel
- Owns state
- Handles intents
- Coordinates async work
3️⃣ Services
- Perform side effects
- Networking
- Persistence
- Analytics
4️⃣ Models
- Pure data
- No logic
- No side effects
Each layer has one responsibility.
🧩 View → ViewModel (Intent-Based Updates)
Views should never “decide” how something happens.
Instead, send intent:
Button("Like") {
viewModel.likeTapped()
}
Not this:
viewModel.post.likes += 1 // ❌
Intent-based APIs keep logic centralized and testable.
🧠 ViewModel Owns the State
@Observable
class FeedViewModel {
var posts: [Post] = []
var loading = false
func load() async { ... }
func likeTapped(postID: String) { ... }
}
Rules:
- Views read state
- ViewModels mutate state
- Services never touch UI state
🔄 Handling Async Without Breaking Data Flow
Async work belongs in the ViewModel:
@MainActor
func load() async {
loading = true
defer { loading = false }
do {
posts = try await api.fetchFeed()
} catch {
errors.present(map(error))
}
}
Key rules:
- Always update state on the main actor
- Never let services mutate ViewModel properties
- Async errors flow back through the ViewModel
📦 Services Are Stateless (or Explicitly Stateful)
Good services:
protocol FeedService {
func fetchFeed() async throws -> [Post]
}
Bad services:
- holding UI state
- mutating ViewModels
- triggering navigation
- showing alerts
Services do work, nothing else.
🧭 Navigation Is State, Not Side Effects
Navigation should be driven by state changes, not imperatively pushed.
@Observable
class AppState {
var route: AppRoute?
}
View reacts:
.onChange(of: appState.route) { route in
navigate(route)
}
This keeps navigation predictable and testable.
🔁 Avoid Two-Way Binding Across Layers
This is dangerous:
ChildView(value: $viewModel.someState) // ⚠️
Instead:
- child emits events
- parent updates state
ChildView(onChange: viewModel.childChanged)
Bindings are for UI composition, not architecture.
🧪 Testing Unidirectional Data Flow
Because data flow is linear, tests become trivial:
func test_like_updates_state() async {
let vm = FeedViewModel(service: MockService())
await vm.load()
vm.likeTapped(postID: "1")
XCTAssertTrue(vm.posts.first!.liked)
}
No UI.
No bindings.
No magic.
🔥 Real-World Rule of Thumb
Ask yourself:
“If I read this file, can I tell who owns the state?”
If the answer is unclear → the architecture is wrong.
🚀 Final Thoughts
Unidirectional data flow gives you:
- predictable updates
- fewer bugs
- easier async handling
- simpler testing
- scalable architecture
- clean separation of concerns
SwiftUI wants this model — once you embrace it, everything clicks.
Top comments (0)