SwiftUI Data Flow & Unidirectional Architecture
Source: Dev.to
What Unidirectional Data Flow Means
- User Action → Event goes down the hierarchy.
- State comes up the hierarchy.
- No shortcuts, no back‑channels, no surprise mutations.
What Breaks 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 Four‑Layer Architecture
| Layer | Responsibility |
|---|---|
| Views | Display state, send user intent (no business logic) |
| ViewModel | Own state, handle intents, coordinate async work |
| Services | Perform side effects (networking, persistence, analytics) |
| Models | Pure data, no logic, no side effects |
Each layer has a single responsibility.
Views: Send Intent, Don’t Mutate State
Button("Like") {
viewModel.likeTapped()
}
Not this:
viewModel.post.likes += 1 // ❌
Intent‑based APIs keep logic centralized and testable.
Example ViewModel
@Observable
class FeedViewModel {
var posts: [Post] = []
var loading = false
func load() async { … }
func likeTapped(postID: String) { … }
}
Rules for State Management
- Views read state.
- ViewModels mutate state.
- Services never touch UI state.
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.
Service Design
Good Service Protocol
protocol FeedService {
func fetchFeed() async throws -> [Post]
}
Bad Service Characteristics
- Holding UI state
- Mutating ViewModels
- Triggering navigation
- Showing alerts
Services should only do work.
Navigation Driven by State
@Observable
class AppState {
var route: AppRoute?
}
.onChange(of: appState.route) { route in
navigate(route)
}
This keeps navigation predictable and testable.
Dangerous Pattern
ChildView(value: $viewModel.someState) // ⚠️
Preferred Pattern
ChildView(onChange: viewModel.childChanged)
Bindings are for UI composition, not for crossing architectural boundaries.
Testing Becomes 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 is required.
Checklist
“If I read this file, can I tell who owns the state?”
If the answer is unclear → the architecture is wrong.
Benefits of Unidirectional Data Flow
- Predictable updates
- Fewer bugs
- Easier async handling
- Simpler testing
- Scalable architecture
- Clean separation of concerns
SwiftUI is built for this model — once you embrace it, everything clicks.