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
@Publishedproperties - 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. │
└─────────────────────────────────────────────────────────────┘
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)
}
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)
}
3. ViewStateReducing
Pure function that transforms domain state to view state:
protocol ViewStateReducing {
associatedtype DomainState
associatedtype ViewState
func reduce(domainState: DomainState) -> ViewState
}
4. DomainEventActionMap
Maps UI events to business actions:
protocol DomainEventActionMap {
associatedtype ViewEvent
associatedtype DomainAction
func map(event: ViewEvent) -> DomainAction?
}
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
}
Step 2: Define Domain Actions
Actions represent business operations:
enum UserListDomainAction: Equatable {
case loadUsers
case refreshUsers
case selectUser(userId: String)
case clearSelectedUser
}
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
)
}
}
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)
}
}
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
}
}
}
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
)
}
}
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)
}
}
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)
}
}
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))
}
}
}
}
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
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
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)
}
Testing the Event Map
@Test
func testEventMapMapsOnAppearToLoadUsers() {
let eventMap = UserListEventActionMap()
let action = eventMap.map(event: .onAppear)
#expect(action == .loadUsers)
}
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)
}
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)
}
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
Key Takeaways
-
Single Source of Truth: One
@Published viewStatein the ViewModel - Pure Reducers: Transform domain state to view state without side effects
- Event Mapping: Separate UI events from business actions
- Testability: Each layer can be tested in isolation
- 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)