DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Data Flow & Unidirectional Architecture

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()
}
Enter fullscreen mode Exit fullscreen mode

Not this:

viewModel.post.likes += 1  // ❌
Enter fullscreen mode Exit fullscreen mode

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) { ... }
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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]
}
Enter fullscreen mode Exit fullscreen mode

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?
}
Enter fullscreen mode Exit fullscreen mode

View reacts:

.onChange(of: appState.route) { route in
    navigate(route)
}
Enter fullscreen mode Exit fullscreen mode

This keeps navigation predictable and testable.


🔁 Avoid Two-Way Binding Across Layers

This is dangerous:

ChildView(value: $viewModel.someState) // ⚠️
Enter fullscreen mode Exit fullscreen mode

Instead:

  • child emits events
  • parent updates state
ChildView(onChange: viewModel.childChanged)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)