SwiftUI Data Flow & Unidirectional Architecture

Published: (December 12, 2025 at 06:18 PM EST)
2 min read
Source: Dev.to

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

LayerResponsibility
ViewsDisplay state, send user intent (no business logic)
ViewModelOwn state, handle intents, coordinate async work
ServicesPerform side effects (networking, persistence, analytics)
ModelsPure 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.

@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.

Back to Blog

Related posts

Read more »

Single State Model Architecture

Problem Statement Modern system architectures often prioritize scale and flexibility at the cost of simplicity and consistency. In the rush to adopt microservi...