DEV Community

Cover image for Unidirectional Data Flow Architecture in SwiftUI
Bruno Mello
Bruno Mello

Posted on

Unidirectional Data Flow Architecture in SwiftUI

A practical guide to building maintainable, testable iOS apps using Combine and Clean Architecture principles.


Why Unidirectional Data Flow?

Traditional iOS architectures often suffer from:

  • State scattered everywhere — data lives in views, controllers, and services
  • Hard to test — tightly coupled components require complex mocking
  • Unpredictable updates — multiple sources can modify the same state

Unidirectional Data Flow (UDF) solves these problems by enforcing a single direction for data to travel:

User Action → Event → Action → State Change → View Update
     ↑                                              │
     └──────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The view never modifies state directly. It only emits events and reads state.


The Architecture

┌─────────────────────────────────────────────────────────────┐
│  View (SwiftUI)                                             │
│  Reads: viewState                                           │
│  Emits: ViewEvent                                           │
└─────────────────────────────────────────────────────────────┘
       │ handle(event:)                     ↑ @Published viewState
       ↓                                    │
┌─────────────────────────────────────────────────────────────┐
│  ViewModel                                                  │
│  - Maps ViewEvent → DomainAction                            │
│  - Transforms DomainState → ViewState                       │
└─────────────────────────────────────────────────────────────┘
       │ dispatch(action:)                  ↑ statePublisher
       ↓                                    │
┌─────────────────────────────────────────────────────────────┐
│  Interactor (Business Logic)                                │
│  - Processes actions                                        │
│  - Mutates state                                            │
│  - Calls services                                           │
└─────────────────────────────────────────────────────────────┘
       │                                    ↑
       ↓                                    │
┌─────────────────────────────────────────────────────────────┐
│  Services (Network, Storage, etc.)                          │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Step 1: Define the Core Protocols

These four protocols form the foundation of the architecture.

CombineViewModel

The ViewModel protocol defines what every presentation layer component must provide:

protocol CombineViewModel: ObservableObject {
    associatedtype ViewState: Equatable
    associatedtype ViewEvent

    var viewState: ViewState { get }
    func handle(event: ViewEvent)
}
Enter fullscreen mode Exit fullscreen mode
  • ViewState — The current UI state (what to render)
  • ViewEvent — User interactions (what happened)
  • handle(event:) — Entry point for user actions

CombineInteractor

The Interactor protocol defines business logic components:

protocol CombineInteractor {
    associatedtype DomainState: Equatable
    associatedtype DomainAction

    var statePublisher: AnyPublisher<DomainState, Never> { get }
    func dispatch(action: DomainAction)
}
Enter fullscreen mode Exit fullscreen mode
  • DomainState — Pure business data
  • DomainAction — Commands that change state
  • statePublisher — Emits state changes via Combine
  • dispatch(action:) — Receives and processes actions

ViewStateReducing

Transforms domain state into UI-friendly view state:

protocol ViewStateReducing {
    associatedtype DomainState
    associatedtype ViewState

    func reduce(domainState: DomainState) -> ViewState
}
Enter fullscreen mode Exit fullscreen mode

This is a pure function — same input always produces the same output.

DomainEventActionMap

Maps user events to business actions:

protocol DomainEventActionMap {
    associatedtype ViewEvent
    associatedtype DomainAction

    func map(event: ViewEvent) -> DomainAction?
}
Enter fullscreen mode Exit fullscreen mode

Returns optional because some events may not require business logic.


Step 2: Build a Feature — User List Example

Let's build a user list feature step by step.

2.1 Define View Events

What can the user do?

enum UserListViewEvent: Equatable {
    case onAppear
    case onRefresh
    case onUserTapped(User)
    case onDeleteTapped(User)
}
Enter fullscreen mode Exit fullscreen mode

These are UI-centric — they describe what happened, not what to do.

2.2 Define Domain Actions

What business operations exist?

enum UserListDomainAction: Equatable {
    case loadUsers
    case refreshUsers
    case selectUser(User)
    case deleteUser(User)
}
Enter fullscreen mode Exit fullscreen mode

These are business-centric — they describe what the app should do.

2.3 Define Domain State

What data does the business logic track?

struct UserListDomainState: Equatable {
    var users: [User]
    var isLoading: Bool
    var isRefreshing: Bool
    var error: String?
    var selectedUser: User?

    static var initial: UserListDomainState {
        UserListDomainState(
            users: [],
            isLoading: false,
            isRefreshing: false,
            error: nil,
            selectedUser: nil
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

2.4 Define View State

What does the UI need to render?

struct UserListViewState: Equatable {
    var users: [UserRowItem]
    var isLoading: Bool
    var isRefreshing: Bool
    var errorMessage: String?
    var showEmptyState: Bool

    static var initial: UserListViewState {
        UserListViewState(
            users: [],
            isLoading: false,
            isRefreshing: false,
            errorMessage: nil,
            showEmptyState: false
        )
    }
}

struct UserRowItem: Identifiable, Equatable {
    let id: String
    let name: String
    let email: String
    let avatarURL: URL?
}
Enter fullscreen mode Exit fullscreen mode

Note: UserRowItem contains only what the UI needs — not the full User model.

2.5 Create the Event-to-Action Map

struct UserListEventActionMap: DomainEventActionMap {
    func map(event: UserListViewEvent) -> UserListDomainAction? {
        switch event {
        case .onAppear:
            return .loadUsers
        case .onRefresh:
            return .refreshUsers
        case .onUserTapped(let user):
            return .selectUser(user)
        case .onDeleteTapped(let user):
            return .deleteUser(user)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

2.6 Create the State Reducer

struct UserListViewStateReducer: ViewStateReducing {
    func reduce(domainState: UserListDomainState) -> UserListViewState {
        UserListViewState(
            users: domainState.users.map { user in
                UserRowItem(
                    id: user.id,
                    name: user.name,
                    email: user.email,
                    avatarURL: user.avatarURL
                )
            },
            isLoading: domainState.isLoading,
            isRefreshing: domainState.isRefreshing,
            errorMessage: domainState.error,
            showEmptyState: !domainState.isLoading
                && !domainState.isRefreshing
                && domainState.users.isEmpty
                && domainState.error == nil
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

2.7 Create the Interactor

final class UserListInteractor: CombineInteractor {
    private let userService: UserService
    private let stateSubject = CurrentValueSubject<UserListDomainState, Never>(.initial)
    private var cancellables = Set<AnyCancellable>()

    var statePublisher: AnyPublisher<UserListDomainState, Never> {
        stateSubject.eraseToAnyPublisher()
    }

    init(userService: UserService) {
        self.userService = userService
    }

    func dispatch(action: UserListDomainAction) {
        switch action {
        case .loadUsers:
            loadUsers()
        case .refreshUsers:
            refreshUsers()
        case .selectUser(let user):
            updateState { $0.selectedUser = user }
        case .deleteUser(let user):
            deleteUser(user)
        }
    }

    private func loadUsers() {
        guard !stateSubject.value.isLoading else { return }

        updateState { state in
            state.isLoading = true
            state.error = nil
        }

        userService.fetchUsers()
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                if case .failure(let error) = completion {
                    self?.updateState { state in
                        state.isLoading = false
                        state.error = error.localizedDescription
                    }
                }
            } receiveValue: { [weak self] users in
                self?.updateState { state in
                    state.users = users
                    state.isLoading = false
                }
            }
            .store(in: &cancellables)
    }

    private func refreshUsers() {
        updateState { $0.isRefreshing = true }

        userService.fetchUsers()
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                if case .failure(let error) = completion {
                    self?.updateState { state in
                        state.isRefreshing = false
                        state.error = error.localizedDescription
                    }
                }
            } receiveValue: { [weak self] users in
                self?.updateState { state in
                    state.users = users
                    state.isRefreshing = false
                }
            }
            .store(in: &cancellables)
    }

    private func deleteUser(_ user: User) {
        updateState { state in
            state.users.removeAll { $0.id == user.id }
        }

        userService.deleteUser(user)
            .sink { _ in } receiveValue: { _ in }
            .store(in: &cancellables)
    }

    // Helper: Immutable state updates
    private func updateState(_ transform: (inout UserListDomainState) -> Void) {
        var state = stateSubject.value
        transform(&state)
        stateSubject.send(state)
    }
}
Enter fullscreen mode Exit fullscreen mode

The updateState helper ensures state is always treated immutably.

2.8 Create the ViewModel

final class UserListViewModel: CombineViewModel, ObservableObject {
    @Published private(set) var viewState: UserListViewState = .initial
    @Published var selectedUser: User?

    private let interactor: UserListInteractor
    private let reducer: UserListViewStateReducer
    private let eventMap: UserListEventActionMap
    private var cancellables = Set<AnyCancellable>()

    init(
        interactor: UserListInteractor,
        reducer: UserListViewStateReducer = UserListViewStateReducer(),
        eventMap: UserListEventActionMap = UserListEventActionMap()
    ) {
        self.interactor = interactor
        self.reducer = reducer
        self.eventMap = eventMap

        setupBindings()
    }

    func handle(event: UserListViewEvent) {
        // Handle navigation side effects
        if case .onUserTapped(let user) = event {
            selectedUser = user
        }

        // Map event to action and dispatch
        guard let action = eventMap.map(event: event) else { return }
        interactor.dispatch(action: action)
    }

    private func setupBindings() {
        interactor.statePublisher
            .map { [reducer] state in reducer.reduce(domainState: state) }
            .receive(on: DispatchQueue.main)
            .assign(to: &$viewState)
    }
}
Enter fullscreen mode Exit fullscreen mode

2.9 Create the View

struct UserListView: View {
    @ObservedObject var viewModel: UserListViewModel

    var body: some View {
        content
            .refreshable {
                viewModel.handle(event: .onRefresh)
            }
            .onAppear {
                viewModel.handle(event: .onAppear)
            }
    }

    @ViewBuilder
    private var content: some View {
        if viewModel.viewState.isLoading {
            ProgressView()
        } else if let error = viewModel.viewState.errorMessage {
            ErrorView(message: error)
        } else if viewModel.viewState.showEmptyState {
            EmptyStateView(message: "No users found")
        } else {
            userList
        }
    }

    private var userList: some View {
        List(viewModel.viewState.users) { user in
            UserRowView(user: user)
                .onTapGesture {
                    viewModel.handle(event: .onUserTapped(/* original User */))
                }
                .swipeActions {
                    Button(role: .destructive) {
                        viewModel.handle(event: .onDeleteTapped(/* original User */))
                    } label: {
                        Label("Delete", systemImage: "trash")
                    }
                }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Dependency Injection with ServiceLocator

A simple ServiceLocator makes testing easy:

final class ServiceLocator {
    private var services: [String: Any] = [:]

    func register<T>(_ type: T.Type, instance: T) {
        let key = String(describing: type)
        services[key] = { instance }
    }

    func retrieve<T>(_ type: T.Type) throws -> T {
        let key = String(describing: type)
        guard let factory = services[key] as? () -> T else {
            throw ServiceLocatorError.notFound(key)
        }
        return factory()
    }
}
Enter fullscreen mode Exit fullscreen mode

Production Setup

// App startup
let serviceLocator = ServiceLocator()
serviceLocator.register(UserService.self, instance: LiveUserService())

// Creating a feature
let interactor = UserListInteractor(
    userService: try serviceLocator.retrieve(UserService.self)
)
let viewModel = UserListViewModel(interactor: interactor)
Enter fullscreen mode Exit fullscreen mode

Test Setup

func createTestServiceLocator() -> ServiceLocator {
    let serviceLocator = ServiceLocator()
    serviceLocator.register(UserService.self, instance: MockUserService())
    return serviceLocator
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Testing Each Layer

Test the Event-to-Action Map

@Suite("UserListEventActionMap Tests")
struct UserListEventActionMapTests {
    let sut = UserListEventActionMap()

    @Test("onAppear maps to loadUsers")
    func testOnAppear() {
        #expect(sut.map(event: .onAppear) == .loadUsers)
    }

    @Test("onRefresh maps to refreshUsers")
    func testOnRefresh() {
        #expect(sut.map(event: .onRefresh) == .refreshUsers)
    }
}
Enter fullscreen mode Exit fullscreen mode

Test the Reducer

@Suite("UserListViewStateReducer Tests")
struct UserListViewStateReducerTests {
    let sut = UserListViewStateReducer()

    @Test("Loading state produces loading view state")
    func testLoadingState() {
        var domainState = UserListDomainState.initial
        domainState.isLoading = true

        let viewState = sut.reduce(domainState: domainState)

        #expect(viewState.isLoading == true)
        #expect(viewState.showEmptyState == false)
    }

    @Test("Empty users with no loading shows empty state")
    func testEmptyState() {
        let domainState = UserListDomainState.initial

        let viewState = sut.reduce(domainState: domainState)

        #expect(viewState.showEmptyState == true)
    }
}
Enter fullscreen mode Exit fullscreen mode

Test the Interactor

@Suite("UserListInteractor Tests")
@MainActor
struct UserListInteractorTests {
    @Test("Load users updates state correctly")
    func testLoadUsers() async throws {
        let mockService = MockUserService()
        mockService.fetchUsersResult = .success([User.mock])

        let sut = UserListInteractor(userService: mockService)

        sut.dispatch(action: .loadUsers)
        try await Task.sleep(nanoseconds: 100_000_000)

        let state = sut.statePublisher.value
        #expect(state.users.count == 1)
        #expect(state.isLoading == false)
    }
}
Enter fullscreen mode Exit fullscreen mode

The Complete Data Flow

Here's the full cycle when a user pulls to refresh:

1. User pulls down on the list
        │
        ↓
2. View calls: viewModel.handle(event: .onRefresh)
        │
        ↓
3. EventMap: .onRefresh → .refreshUsers
        │
        ↓
4. ViewModel: interactor.dispatch(action: .refreshUsers)
        │
        ↓
5. Interactor:
   - Sets isRefreshing = true
   - Calls userService.fetchUsers()
   - On success: updates users, sets isRefreshing = false
        │
        ↓
6. Interactor: stateSubject.send(newState)
        │
        ↓
7. ViewModel binding: statePublisher → reducer.reduce() → viewState
        │
        ↓
8. @Published viewState triggers SwiftUI update
        │
        ↓
9. View re-renders with new data
Enter fullscreen mode Exit fullscreen mode

Key Benefits

Predictable State

State only changes through actions. You can log every action to understand exactly what happened.

Easy Testing

  • Reducers — Pure functions, no mocking needed
  • Event maps — Pure functions, no mocking needed
  • Interactors — Inject mock services
  • ViewModels — Inject mock interactors

Clear Separation

  • View — Only UI rendering logic
  • ViewModel — Presentation logic and state transformation
  • Interactor — Business logic and state management
  • Services — External integrations

Scalable Patterns

Every feature follows the same structure. New team members can understand and contribute faster.


When to Use This Architecture

Good fit:

  • Apps with complex state management
  • Teams that value testability
  • Apps that need to scale over time
  • Features with multiple data sources

Overkill for:

  • Simple utility apps
  • Prototypes and MVPs
  • Features with trivial state

Quick Reference: Adding a New Feature

  1. Create FeatureViewEvent.swift — User interactions
  2. Create FeatureDomainAction.swift — Business commands
  3. Create FeatureDomainState.swift — Business data
  4. Create FeatureViewState.swift — UI data
  5. Create FeatureEventActionMap.swift — Event → Action
  6. Create FeatureViewStateReducer.swift — DomainState → ViewState
  7. Create FeatureInteractor.swift — Business logic
  8. Create FeatureViewModel.swift — Presentation logic
  9. Create FeatureView.swift — SwiftUI view
  10. Write tests for each layer

Conclusion

Unidirectional Data Flow provides a clear, predictable way to manage state in SwiftUI apps. By separating concerns into distinct layers with well-defined protocols, you get:

  • Maintainability — Each layer has a single responsibility
  • Testability — Pure functions and dependency injection
  • Scalability — Consistent patterns across features
  • Debuggability — State changes are traceable

The initial setup requires more files than simpler architectures, but the investment pays off as your app grows in complexity.

Top comments (0)