DEV Community

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

Posted on • Edited on

Unidirectional Data Flow Architecture in SwiftUI

Building scalable SwiftUI applications requires a predictable state management pattern. This article presents a Unidirectional Data Flow (UDF) architecture that enforces a single direction for data: User Action → Event → Action → State Change → View Update.

The Problem

Traditional iOS architectures often suffer from:

  • Scattered state across multiple @Published properties
  • Testing challenges due to tight coupling between layers
  • Unpredictable updates when multiple sources modify the same state
  • Difficult debugging when state changes come from multiple directions

The Solution: Unidirectional Data Flow

┌─────────────────────────────────────────────────────────────┐
│  View (SwiftUI)                                             │
│  @ObservedObject viewModel                                  │
└─────────────────────────────────────────────────────────────┘
       │ handle(event: ViewEvent)           ↑ @Published viewState
       ↓                                    │
┌─────────────────────────────────────────────────────────────┐
│  ViewModel (CombineViewModel)                               │
│  - EventActionMap: ViewEvent → DomainAction                 │
│  - Reducer: DomainState → ViewState                         │
│  - SINGLE @Published viewState (one source of truth)        │
└─────────────────────────────────────────────────────────────┘
       │ dispatch(action:)                  ↑ statePublisher
       ↓                                    │
┌─────────────────────────────────────────────────────────────┐
│  DomainInteractor (CombineInteractor)                       │
│  - CurrentValueSubject<DomainState, Never>                  │
│  - Business logic + state mutations                         │
└─────────────────────────────────────────────────────────────┘
       │                                    ↑
       ↓                                    │
┌─────────────────────────────────────────────────────────────┐
│  Service Layer (Protocol-based)                             │
│  - Network, Storage, etc.                                   │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Four Core Protocols

1. CombineViewModel

The ViewModel protocol enforces a single source of truth with one @Published property:

protocol CombineViewModel: ObservableObject {
    associatedtype ViewState: Equatable
    associatedtype ViewEvent

    var viewState: ViewState { get }
    func handle(event: ViewEvent)
}
Enter fullscreen mode Exit fullscreen mode

Key principle: One @Published viewState property contains ALL UI state. This eliminates scattered state and makes the view's data source predictable.

2. CombineInteractor

The DomainInteractor handles business logic and state management:

protocol CombineInteractor {
    associatedtype DomainState: Equatable
    associatedtype DomainAction

    var statePublisher: AnyPublisher<DomainState, Never> { get }
    func dispatch(action: DomainAction)
}
Enter fullscreen mode Exit fullscreen mode

3. ViewStateReducing

Pure function that transforms domain state to view state:

protocol ViewStateReducing {
    associatedtype DomainState
    associatedtype ViewState

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

4. DomainEventActionMap

Maps UI events to business actions:

protocol DomainEventActionMap {
    associatedtype ViewEvent
    associatedtype DomainAction

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

Complete Implementation Example

Let's build a User List feature to demonstrate the architecture.

Step 1: Define View Events

Events represent user interactions:

enum UserListViewEvent: Equatable {
    case onAppear
    case onRefresh
    case onUserTapped(userId: String)
    case onUserNavigated
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Define Domain Actions

Actions represent business operations:

enum UserListDomainAction: Equatable {
    case loadUsers
    case refreshUsers
    case selectUser(userId: String)
    case clearSelectedUser
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Define Domain State

Business state with all relevant data:

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

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

Step 4: Define View State

UI-optimized state derived from domain state:

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

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

struct UserViewItem: Identifiable, Equatable {
    let id: String
    let displayName: String
    let avatarURL: URL?

    init(from user: User) {
        id = user.id
        displayName = "\(user.firstName) \(user.lastName)"
        avatarURL = URL(string: user.avatarUrl)
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Event to Action Mapping

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

Step 6: View State Reducer

Pure transformation from domain to view state:

struct UserListViewStateReducer: ViewStateReducing {
    func reduce(domainState: UserListDomainState) -> UserListViewState {
        UserListViewState(
            users: domainState.users.map { UserViewItem(from: $0) },
            isLoading: domainState.isLoading,
            isRefreshing: domainState.isRefreshing,
            errorMessage: domainState.error,
            showEmptyState: !domainState.isLoading
                && !domainState.isRefreshing
                && domainState.users.isEmpty
                && domainState.hasLoadedInitialData,
            selectedUser: domainState.selectedUser
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Domain Interactor

Note the naming convention: *DomainInteractor:

final class UserListDomainInteractor: CombineInteractor {
    typealias DomainState = UserListDomainState
    typealias DomainAction = UserListDomainAction

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

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

    var currentState: UserListDomainState {
        stateSubject.value
    }

    init(serviceLocator: ServiceLocator) {
        self.userService = try! serviceLocator.retrieve(UserService.self)
    }

    func dispatch(action: UserListDomainAction) {
        switch action {
        case .loadUsers:
            loadUsers()
        case .refreshUsers:
            refreshUsers()
        case let .selectUser(userId):
            selectUser(userId: userId)
        case .clearSelectedUser:
            clearSelectedUser()
        }
    }

    private func loadUsers() {
        guard !currentState.isLoading, !currentState.hasLoadedInitialData else { return }

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

        userService.fetchUsers()
            .sink { [weak self] completion in
                if case let .failure(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
                    state.hasLoadedInitialData = true
                }
            }
            .store(in: &cancellables)
    }

    private func refreshUsers() {
        updateState { state in
            state.isRefreshing = true
            state.error = nil
        }

        userService.fetchUsers()
            .sink { [weak self] completion in
                if case let .failure(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 selectUser(userId: String) {
        guard let user = currentState.users.first(where: { $0.id == userId }) else { return }
        updateState { state in
            state.selectedUser = user
        }
    }

    private func clearSelectedUser() {
        updateState { state in
            state.selectedUser = nil
        }
    }

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

Step 8: ViewModel (Single Source of Truth)

Critical: The ViewModel has a single @Published viewState property:

@MainActor
final class UserListViewModel: CombineViewModel, ObservableObject {
    typealias ViewState = UserListViewState
    typealias ViewEvent = UserListViewEvent

    // SINGLE source of truth - one @Published property
    @Published private(set) var viewState: UserListViewState = .initial

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

    init(
        serviceLocator: ServiceLocator,
        reducer: UserListViewStateReducer = UserListViewStateReducer(),
        eventMap: UserListEventActionMap = UserListEventActionMap()
    ) {
        self.domainInteractor = UserListDomainInteractor(serviceLocator: serviceLocator)
        self.reducer = reducer
        self.eventMap = eventMap

        setupBindings()
    }

    func handle(event: UserListViewEvent) {
        guard let action = eventMap.map(event: event) else { return }
        domainInteractor.dispatch(action: action)
    }

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

Step 9: SwiftUI View

The view reads from the single viewState and emits events:

struct UserListView<R: UserListNavigationRouter>: View {
    private var router: R
    @ObservedObject var viewModel: UserListViewModel

    init(router: R, viewModel: UserListViewModel) {
        self.router = router
        self.viewModel = viewModel
    }

    var body: some View {
        content
            .navigationTitle("Users")
            .refreshable {
                viewModel.handle(event: .onRefresh)
            }
            .onAppear {
                viewModel.handle(event: .onAppear)
            }
            .onChange(of: viewModel.viewState.selectedUser) { _, newValue in
                if let user = newValue {
                    router.route(navigationEvent: .userDetail(user))
                    viewModel.handle(event: .onUserNavigated)
                }
            }
    }

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

    private var userList: some View {
        List(viewModel.viewState.users) { user in
            UserRow(user: user)
                .onTapGesture {
                    viewModel.handle(event: .onUserTapped(userId: user.id))
                }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Why Single @Published Matters

Having a single @Published viewState property is crucial:

Before (Anti-pattern)

// DON'T DO THIS - Multiple sources of truth
@Published var users: [User] = []
@Published var isLoading: Bool = false
@Published var error: String? = nil
Enter fullscreen mode Exit fullscreen mode

Problems:

  • View can receive partial updates
  • State can become inconsistent
  • Harder to reason about current state
  • Testing requires checking multiple properties

After (Correct Pattern)

// DO THIS - Single source of truth
@Published private(set) var viewState: UserListViewState = .initial
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Atomic state updates
  • Consistent state guaranteed
  • Easy to snapshot and debug
  • Single property to test

Testing Strategy

Testing the Reducer (Pure Function)

@Test
func testReducerShowsEmptyState() {
    let reducer = UserListViewStateReducer()
    let domainState = UserListDomainState(
        users: [],
        isLoading: false,
        isRefreshing: false,
        error: nil,
        hasLoadedInitialData: true,
        selectedUser: nil
    )

    let viewState = reducer.reduce(domainState: domainState)

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

Testing the Event Map

@Test
func testEventMapMapsOnAppearToLoadUsers() {
    let eventMap = UserListEventActionMap()

    let action = eventMap.map(event: .onAppear)

    #expect(action == .loadUsers)
}
Enter fullscreen mode Exit fullscreen mode

Testing the DomainInteractor

@Test
func testDomainInteractorLoadsUsers() async {
    let mockService = MockUserService()
    mockService.usersToReturn = [User(id: "1", firstName: "John", lastName: "Doe")]

    let serviceLocator = ServiceLocator()
    serviceLocator.register(UserService.self, instance: mockService)

    let interactor = UserListDomainInteractor(serviceLocator: serviceLocator)

    interactor.dispatch(action: .loadUsers)

    // Wait for async operation
    try? await Task.sleep(nanoseconds: 100_000_000)

    #expect(interactor.currentState.users.count == 1)
    #expect(interactor.currentState.hasLoadedInitialData == true)
}
Enter fullscreen mode Exit fullscreen mode

Testing the ViewModel

@Test
@MainActor
func testViewModelUpdatesViewStateOnLoad() async {
    let serviceLocator = ServiceLocator()
    serviceLocator.register(UserService.self, instance: MockUserService())

    let viewModel = UserListViewModel(serviceLocator: serviceLocator)

    viewModel.handle(event: .onAppear)

    // Wait for state propagation
    try? await Task.sleep(nanoseconds: 100_000_000)

    #expect(viewModel.viewState.users.isEmpty == false)
}
Enter fullscreen mode Exit fullscreen mode

File Structure

Organize each feature with this structure:

UserList/
├── API/
│   └── UserService.swift           # Protocol + Live/Mock implementations
├── Domain/
│   ├── UserListDomainInteractor.swift
│   ├── UserListDomainState.swift
│   ├── UserListDomainAction.swift
│   ├── UserListViewStateReducer.swift
│   └── UserListEventActionMap.swift
├── ViewModel/
│   └── UserListViewModel.swift
├── View/
│   └── UserListView.swift
├── ViewEvents/
│   └── UserListViewEvent.swift
├── ViewStates/
│   └── UserListViewState.swift
└── Router/
    └── UserListNavigationRouter.swift
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Single Source of Truth: One @Published viewState in the ViewModel
  2. Pure Reducers: Transform domain state to view state without side effects
  3. Event Mapping: Separate UI events from business actions
  4. Testability: Each layer can be tested in isolation
  5. Predictable Flow: Data flows in one direction only

Conclusion

This Unidirectional Data Flow architecture provides:

  • Predictable state management through single source of truth
  • Easy debugging with clear data flow
  • Excellent testability at every layer
  • Scalable patterns that work for small and large features
  • Clean separation between UI, presentation, and business logic

The pattern requires more initial setup but pays dividends in maintainability, testability, and developer experience as your application grows.


Top comments (0)