iOS Development

MVVM + Reducer Pattern

20 min readRafał Dubiel
#iOS#MVVM#Reducer pattern

After over 8 years in the industry and experience with various architectures - from simple MVC, through MVVM, VIPER, TCA to Clean Architecture, I've come to a conclusion that may seem controversial:

The best architecture is one you control, understand, and can adapt to project needs.

This article isn't another "TCA vs Clean Architecture" comparison. It's a proposal for a pragmatic approach that combines the predictability of the reducer pattern with MVVM without tying yourself to any framework.


TL;DR

  • Classic MVVM in iOS doesn't scale well - ViewModels quickly turn into uncontrolled collections of mutations,
  • The reducer pattern solves this problem by enforcing a single source of truth, explicit actions, and predictable state changes,
  • You don't need TCA - reducer is a concept, not a framework,
  • The reducer can live locally in the ViewModel, without interfering with the domain layer and without vendor lock-in,
  • Asynchronous operations should be effects, not places where state is mutated,
  • Navigation as part of state fits well with declarative NavigationStack,
  • This approach adds boilerplate, but radically improves debugging, testability, and predictability,
  • Use it where state is complex - not everywhere.

The Problem We're Solving

Before we get to the solution, let's define the problem. ViewModels in classic MVVM often evolve unpredictably:

// Typical ViewModel after several months of development
@Observable
@MainActor
final class TransferViewModel {
    var accounts: [Account] = []
    var selectedFrom: Account?
    var selectedTo: Account?
    var amount: Decimal = 0
    var isLoading = false
    var error: Error?

    func loadAccounts() async {
        isLoading = true  // mutation #1
        error = nil       // mutation #2
        do {
            accounts = try await api.fetchAccounts() // mutation #3
            isLoading = false  // mutation #4
        } catch {
            self.error = error  // mutation #5
            isLoading = false   // mutation #6
        }
    }

    func selectAccount(_ account: Account) {
        selectedFrom = account  // mutation #7
        error = nil             // mutation #8
    }

    func submitTransfer() async {
        isLoading = true  // mutation #9
        // ... more mutations scattered throughout the method
    }

    // ... 20 other methods with scattered mutations
}

What's Wrong With This Code?

  • Scattered mutations - state changes in many places, hard to trace the flow,
  • Implicit state transitions - it's not clear "what caused this change",
  • Difficult debugging - breakpoints must be set in many places,
  • Race conditions - asynchronous mutations can overlap,
  • Poor auditability - in banking applications, for example, you need to know what changed and when.

Reducer Pattern - A Concept, Not a Framework

The reducer pattern isn't an invention of TCA or Redux. It's a simple concept from functional programming:

newState = reduce(currentState, action)

Key Characteristics:

  • Pure function - same inputs always produce the same output,
  • Single source of truth - entire state in one place,
  • Explicit transitions - every change is a specific action,
  • Debuggable - single entry point for all changes.

The problem with TCA isn't the reducer pattern itself, but that the framework imposes an entire architecture, requires studying an extensive API, and creates vendor lock-in.


Hybrid Approach: MVVM + Local Reducer

The proposal is simple: take the reducer from TCA and add it to MVVM. The reducer lives ONLY in the presentation layer. The domain layer remains clean, independent of any patterns or frameworks.


Step-by-Step Implementation

Step 1: Domain Layer (here an example with Clean Architecture) - Pure Business Logic

Let's start with the layer that doesn't change regardless of whether we use reducers, MVVM, or anything else:

final class TransferMoneyUseCase {
    private let accountRepository: AccountRepository
    private let transactionRepository: TransactionRepository
    private let validationService: TransactionValidationService

    func execute(
        from: AccountID,
        to: AccountID,
        amount: Decimal
    ) async throws -> Transaction {
        try validationService.validate(amount: amount, from: from)

        let balance = try await accountRepository.getBalance(for: from)
        guard balance >= amount else {
            throw TransferError.insufficientFunds
        }

        return try await transactionRepository.createTransfer(
            from: from,
            to: to,
            amount: amount
        )
    }
}

Notice: The Use Case knows nothing about UI, reducers, or SwiftUI. It's pure business logic that you can test in isolation and potentially share across platforms.

Step 2: ViewModel with Built-in Reducer (iOS 17+ Observation)

Since iOS 17, we have the new Observation framework with the @Observable macro, which replaces @Published and is much lighter. You no longer need @Published on state - a regular property is enough, and SwiftUI automatically tracks changes.

import Observation

@Observable
@MainActor
final class TransferViewModel {

    // MARK: - State (single source of truth)
    private(set) var state: State = .initial

    private let transferUseCase: TransferMoneyUseCase
    private let getAccountsUseCase: GetAccountsUseCase

    private var currentTask: Task<Void, Never>?

    deinit {
        currentTask?.cancel()
    }

    // MARK: - State Definition

    struct State: Equatable {
        var accounts: [Account] = []
        var selectedFrom: Account?
        var selectedTo: Account?
        var amount: Decimal = 0
        var isLoading = false
        var error: TransferError?
        var lastTransaction: Transaction?

        var canSubmit: Bool {
            selectedFrom != nil &&
            selectedTo != nil &&
            amount > 0 &&
            selectedFrom?.id != selectedTo?.id
        }

        static let initial = State()
    }

    // MARK: - Actions (explicit intents)

    enum Action: Equatable {
        case loadAccounts
        case accountsLoaded([Account])
        case selectFromAccount(Account)
        case selectToAccount(Account)
        case updateAmount(Decimal)
        case submitTransfer
        case transferCompleted(Transaction)
        case transferFailed(TransferError)
        case resetError
    }


    func send(_ action: Action) {
        state = reduce(state: state, action: action)
        handleEffect(for: action)
    }

    // MARK: - Reducer (pure, synchronous, predictable)

    private func reduce(state: State, action: Action) -> State {
        var newState = state

        switch action {
        case .loadAccounts:
            newState.isLoading = true
            newState.error = nil

        case .accountsLoaded(let accounts):
            newState.accounts = accounts
            newState.isLoading = false

        case .selectFromAccount(let account):
            newState.selectedFrom = account
            newState.error = nil

        case .selectToAccount(let account):
            newState.selectedTo = account
            newState.error = nil

        case .updateAmount(let amount):
            newState.amount = amount
            newState.error = nil

        case .submitTransfer:
            newState.isLoading = true
            newState.error = nil

        case .transferCompleted(let transaction):
            newState.isLoading = false
            newState.lastTransaction = transaction
            newState.selectedFrom = nil
            newState.selectedTo = nil
            newState.amount = 0

        case .transferFailed(let error):
            newState.isLoading = false
            newState.error = error

        case .resetError:
            newState.error = nil
        }

        return newState
    }

    // MARK: - Effects (structured concurrency)

    private func handleEffect(for action: Action) {
        switch action {
        case .loadAccounts:
            currentTask?.cancel()
            currentTask = Task { [weak self] in
                await self?.loadAccounts()
            }

        case .submitTransfer:
            currentTask?.cancel()
            currentTask = Task { [weak self] in
                await self?.submitTransfer()
            }

        default:
            break
        }
    }

    private func loadAccounts() async {
        guard !Task.isCancelled else { return }

        do {
            let accounts = try await getAccountsUseCase.execute()
            guard !Task.isCancelled else { return }
            send(.accountsLoaded(accounts))
        } catch {
            guard !Task.isCancelled else { return }
            send(.transferFailed(.networkError))
        }
    }

    private func submitTransfer() async {
        guard !Task.isCancelled else { return }

        guard let from = state.selectedFrom,
              let to = state.selectedTo else {
            send(.transferFailed(.invalidSelection))
            return
        }

        do {
            let transaction = try await transferUseCase.execute(
                from: from.id,
                to: to.id,
                amount: state.amount
            )
            guard !Task.isCancelled else { return }
            send(.transferCompleted(transaction))
        } catch {
            guard !Task.isCancelled else { return }
            let transferError = (error as? TransferError) ?? .unknown
            send(.transferFailed(transferError))
        }
    }
}

Note on Structured Concurrency

In the code above, we use several important operations:

  1. currentTask?.cancel() before new Task - prevents race conditions when user clicks rapidly,
  2. Task.isCancelled checks - we respect cancellation at every await point,
  3. [weak self] - prevents retain cycles,
  4. deinit { currentTask?.cancel() } - cleanup on deallocation.

For more complex scenarios (e.g., multiple parallel effects), consider using TaskGroup or a dedicated Actor:

@Observable
@MainActor
final class ComplexViewModel {
    private let effectsActor = EffectsActor()

    actor EffectsActor {
        private var tasks: [String: Task<Void, Never>] = [:]

        func run(id: String, operation: @escaping @Sendable () async -> Void) {
            tasks[id]?.cancel()
            tasks[id] = Task { await operation() }
        }

        func cancelAll() {
            tasks.values.forEach { $0.cancel() }
            tasks.removeAll()
        }
    }
}

Scaling the Reducer - Avoiding the "Massive Reducer"

For simple screens, a flat switch in reduce() is fine. But when you have 20+ actions, the reducer becomes unreadable - analogous to the "Massive ViewModel" problem.

Solution: Reducer Composition

private func reduce(state: State, action: Action) -> State {
    var newState = state

    newState = reduceLoading(state: newState, action: action)
    newState = reduceSelection(state: newState, action: action)
    newState = reduceTransfer(state: newState, action: action)
    newState = reduceError(state: newState, action: action)

    return newState
}

// MARK: - Loading Reducer

private func reduceLoading(state: State, action: Action) -> State {
    var newState = state

    switch action {
    case .loadAccounts, .submitTransfer:
        newState.isLoading = true

    case .accountsLoaded, .transferCompleted, .transferFailed:
        newState.isLoading = false

    default:
        break
    }

    return newState
}

// MARK: - Selection Reducer

private func reduceSelection(state: State, action: Action) -> State {
    var newState = state

    switch action {
    case .selectFromAccount(let account):
        newState.selectedFrom = account

    case .selectToAccount(let account):
        newState.selectedTo = account

    case .updateAmount(let amount):
        newState.amount = amount

    case .transferCompleted:
        // Reset selection after success
        newState.selectedFrom = nil
        newState.selectedTo = nil
        newState.amount = 0

    default:
        break
    }

    return newState
}

// MARK: - Transfer Reducer

private func reduceTransfer(state: State, action: Action) -> State {
    var newState = state

    switch action {
    case .accountsLoaded(let accounts):
        newState.accounts = accounts

    case .transferCompleted(let transaction):
        newState.lastTransaction = transaction

    default:
        break
    }

    return newState
}

// MARK: - Error Reducer

private func reduceError(state: State, action: Action) -> State {
    var newState = state

    switch action {
    case .transferFailed(let error):
        newState.error = error

    case .resetError, .loadAccounts, .submitTransfer,
         .selectFromAccount, .selectToAccount, .updateAmount:
        newState.error = nil

    default:
        break
    }

    return newState
}

Benefits of Composition:

  • Each sub-reducer is responsible for one aspect of state,
  • Easier unit testing,
  • More readable code - open reduceError() and see all error logic,
  • Reducers can be extracted to separate files for very large ViewModels

Alternative: Action Grouping

enum Action: Equatable {
    enum Loading: Equatable {
        case started
        case finished
    }

    enum Selection: Equatable {
        case fromAccount(Account)
        case toAccount(Account)
        case amount(Decimal)
        case reset
    }

    enum Transfer: Equatable {
        case submit
        case completed(Transaction)
        case failed(TransferError)
    }

    case loading(Loading)
    case selection(Selection)
    case transfer(Transfer)
    case accountsLoaded([Account])
}

private func reduce(state: State, action: Action) -> State {
    switch action {
    case .loading(let loadingAction):
        return reduceLoading(state: state, action: loadingAction)
    case .selection(let selectionAction):
        return reduceSelection(state: state, action: selectionAction)
    case .transfer(let transferAction):
        return reduceTransfer(state: state, action: transferAction)
    case .accountsLoaded(let accounts):
        var newState = state
        newState.accounts = accounts
        return newState
    }
}

View with Custom Bindings

For longer forms, manually creating Binding with send() becomes tedious. Here's a pattern with custom bindings:

struct TransferView: View {
    @State private var viewModel: TransferViewModel

    var body: some View {
        Form {
            accountsSection
            amountSection
            submitButton
        }
        .alert("Error", isPresented: hasError) {
            Button("OK") { viewModel.send(.resetError) }
        } message: {
            if let error = viewModel.state.error {
                Text(error.localizedDescription)
            }
        }
        .task {
            viewModel.send(.loadAccounts)
        }
    }

    // MARK: - Computed Bindings

    private var hasError: Binding<Bool> {
        Binding(
            get: { viewModel.state.error != nil },
            set: { if !$0 { viewModel.send(.resetError) } }
        )
    }

    private var amount: Binding<Decimal> {
        Binding(
            get: { viewModel.state.amount },
            set: { viewModel.send(.updateAmount($0)) }
        )
    }

    private var selectedFrom: Binding<Account?> {
        Binding(
            get: { viewModel.state.selectedFrom },
            set: { if let account = $0 { viewModel.send(.selectFromAccount(account)) } }
        )
    }

    private var selectedTo: Binding<Account?> {
        Binding(
            get: { viewModel.state.selectedTo },
            set: { if let account = $0 { viewModel.send(.selectToAccount(account)) } }
        )
    }

    // MARK: - Sections

    private var accountsSection: some View {
        Section("Transfer Details") {
            Picker("From", selection: selectedFrom) {
                Text("Select account").tag(nil as Account?)
                ForEach(viewModel.state.accounts) { account in
                    Text(account.name).tag(account as Account?)
                }
            }

            Picker("To", selection: selectedTo) {
                Text("Select account").tag(nil as Account?)
                ForEach(viewModel.state.accounts) { account in
                    Text(account.name).tag(account as Account?)
                }
            }
        }
    }

    private var amountSection: some View {
        Section("Amount") {
            TextField("Amount", value: amount, format: .currency(code: "PLN"))
                .keyboardType(.decimalPad)
        }
    }

    private var submitButton: some View {
        Button("Transfer") {
            viewModel.send(.submitTransfer)
        }
        .disabled(!viewModel.state.canSubmit || viewModel.state.isLoading)
    }
}

Generic Binding Helper

For even cleaner code, you can create an extension:

extension TransferViewModel {
    func binding<Value>(
        for keyPath: KeyPath<State, Value>,
        send action: @escaping (Value) -> Action
    ) -> Binding<Value> {
        Binding(
            get: { self.state[keyPath: keyPath] },
            set: { self.send(action($0)) }
        )
    }
}

// Usage in View:
TextField("Amount", value: viewModel.binding(
    for: \.amount,
    send: { .updateAmount($0) }
), format: .currency(code: "PLN"))

Navigation as Part of State

For a long time, coordinators that imperatively controlled navigation were the standard. With the popularization of Reducer-based architectures and SwiftUI, more and more teams are deciding to model navigation as an element of global state. This approach fits better with the declarative nature of NavigationStack, which renders the view solely based on the current state.

Option 1: Navigation in ViewModel (Local)

@Observable
final class TransferViewModel {
    private(set) var state: State = .initial

    struct State: Equatable {
        var accounts: [Account] = []
        var amount: Decimal = 0

        // Navigation state
        var destination: Destination?

        enum Destination: Equatable, Hashable {
            case confirmation(TransferDetails)
            case receipt(Transaction)
            case accountDetails(Account)
        }
    }

    enum Action: Equatable {
        case navigate(State.Destination?)
        case confirmTransfer
        case showReceipt(Transaction)
    }

    private func reduce(state: State, action: Action) -> State {
        var newState = state

        switch action {
        case .navigate(let destination):
            newState.destination = destination

        case .confirmTransfer:
            let details = TransferDetails(
                from: state.selectedFrom!,
                to: state.selectedTo!,
                amount: state.amount
            )
            newState.destination = .confirmation(details)

        case .showReceipt(let transaction):
            newState.destination = .receipt(transaction)

        // ... other cases
        default:
            break
        }

        return newState
    }
}

// View
struct TransferView: View {
    @State private var viewModel: TransferViewModel

    var body: some View {
        NavigationStack {
            Form { /* ... */ }
                .navigationDestination(item: $viewModel.state.destination) { destination in
                    switch destination {
                    case .confirmation(let details):
                        ConfirmationView(details: details)
                    case .receipt(let transaction):
                        ReceiptView(transaction: transaction)
                    case .accountDetails(let account):
                        AccountDetailsView(account: account)
                    }
                }
        }
    }
}

Option 2: Global AppReducer with NavigationPath()

For applications with many navigation paths, you can use NavigationPath as part of global state:

@Observable
final class AppStore {
    private(set) var state: AppState = .init()

    struct AppState: Equatable {
        var navigationPath = NavigationPath()
        var transferState: TransferState = .init()
        var settingsState: SettingsState = .init()
        // ... other feature states
    }

    enum AppRoute: Hashable {
        case transfer
        case transferConfirmation(TransferDetails)
        case receipt(Transaction)
        case settings
        case accountDetails(Account)
    }

    enum Action {
        case navigate(AppRoute)
        case popToRoot
        case pop
        case transfer(TransferAction)
        case settings(SettingsAction)
    }

    func send(_ action: Action) {
        state = reduce(state: state, action: action)
        handleEffect(for: action)
    }

    private func reduce(state: AppState, action: Action) -> AppState {
        var newState = state

        switch action {
        case .navigate(let route):
            newState.navigationPath.append(route)

        case .popToRoot:
            newState.navigationPath = NavigationPath()

        case .pop:
            if !newState.navigationPath.isEmpty {
                newState.navigationPath.removeLast()
            }

        case .transfer(let transferAction):
            newState.transferState = reduceTransfer(
                state: newState.transferState,
                action: transferAction
            )

        case .settings(let settingsAction):
            newState.settingsState = reduceSettings(
                state: newState.settingsState,
                action: settingsAction
            )
        }

        return newState
    }
}

// Root View
struct ContentView: View {
    @State private var store = AppStore()

    var body: some View {
        NavigationStack(path: $store.state.navigationPath) {
            HomeView()
                .navigationDestination(for: AppStore.AppRoute.self) { route in
                    switch route {
                    case .transfer:
                        TransferView(store: store)
                    case .transferConfirmation(let details):
                        ConfirmationView(details: details, store: store)
                    case .receipt(let transaction):
                        ReceiptView(transaction: transaction)
                    case .settings:
                        SettingsView(store: store)
                    case .accountDetails(let account):
                        AccountDetailsView(account: account)
                    }
                }
        }
        .environment(store)
    }
}

Benefits of Global Store:

  • Single NavigationPath for the entire application,
  • Easy deep linking,
  • Ability to "pop to root" from anywhere,
  • Consistent with reducer pattern,

Drawbacks:

  • More boilerplate,
  • All routes must be Hashable,
  • More difficult unit testing of features in isolation,
  • In larger applications, it quickly becomes a "god object" where all features must know about global routes.

Before and After - Extended Comparison

Aspect❌ Classic MVVM✅ MVVM + Reducer🟡 TCA
MutationsScattered across methodsAll in reduce()All in reduce()
FlowHard to traceAction → State explicitAction → State explicit
DebuggingBreakpoints everywhereSingle breakpointSingle breakpoint + tools
Race conditionsPossibleSignificantly limited and easier to controlEffect system
Audit trailNoneAction loggingBuilt-in
BoilerplateMinimalMedium (State + Action)High (Reducer, Store, Effect)
Learning curveLowMediumHigh
Vendor lock-inNoneNoneStrong
NavigationAnyAny or in StateBuilt-in system
TestingMocks everywhereSimple unit testsExcellent, but requires learning
OnboardingFastFastRequires training
VersioningNo changesNo changesBreaking changes between versions

Debugging - Single Entry Point

One of the biggest advantages of this approach is the ease of debugging. You can add simple logging:

private func reduce(state: State, action: Action) -> State {
    #if DEBUG
    let startTime = CFAbsoluteTimeGetCurrent()
    print("🔄 [\(type(of: self))] Action: \(action)")
    #endif

    var newState = state

    #if DEBUG
    let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
    if newState != state {
        print("📊 State changed in \(String(format: "%.4f", timeElapsed))s")
        printStateDiff(old: state, new: newState)
    }
    #endif

    return newState
}

#if DEBUG
private func printStateDiff(old: State, new: State) {
    let mirror = Mirror(reflecting: new)
    for child in mirror.children {
        guard let label = child.label else { continue }
        let oldMirror = Mirror(reflecting: old)
        if let oldChild = oldMirror.children.first(where: { $0.label == label }) {
            if "\(child.value)" != "\(oldChild.value)" {
                print("   ├─ \(label): \(oldChild.value)\(child.value)")
            }
        }
    }
}
#endif

In the console, you see exactly what's happening:

🔄 [TransferViewModel] Action: loadAccounts
📊 State changed in 0.0001s
   ├─ isLoading: false → true
   ├─ error: Optional(networkError) → nil
🔄 [TransferViewModel] Action: accountsLoaded([Account...])
📊 State changed in 0.0002s
   ├─ accounts: [] → [Account(id: 1), Account(id: 2)]
   ├─ isLoading: true → false

Testing

Testing the Reducer (Synchronous, Deterministic)

final class TransferViewModelTests: XCTestCase {

    var viewModel: TransferViewModel!
    var mockTransferUseCase: MockTransferUseCase!
    var mockAccountsUseCase: MockGetAccountsUseCase!

    override func setUp() {
        super.setUp()
        mockTransferUseCase = MockTransferUseCase()
        mockAccountsUseCase = MockGetAccountsUseCase()
        viewModel = TransferViewModel(
            transferUseCase: mockTransferUseCase,
            getAccountsUseCase: mockAccountsUseCase
        )
    }

    // MARK: - Reducer Tests (synchronous)

    func testLoadAccountsSetsLoadingState() {
        // Given
        XCTAssertFalse(viewModel.state.isLoading)

        // When
        viewModel.send(.loadAccounts)

        // Then - immediately, no async
        XCTAssertTrue(viewModel.state.isLoading)
        XCTAssertNil(viewModel.state.error)
    }

    func testSelectFromAccountUpdatesState() {
        // Given
        let account = Account(id: "1", name: "Main")

        // When
        viewModel.send(.selectFromAccount(account))

        // Then
        XCTAssertEqual(viewModel.state.selectedFrom, account)
    }

    func testCanSubmitRequiresAllFields() {
        // Given
        let from = Account(id: "1", name: "From")
        let to = Account(id: "2", name: "To")

        // Initially false
        XCTAssertFalse(viewModel.state.canSubmit)

        // After selecting from
        viewModel.send(.selectFromAccount(from))
        XCTAssertFalse(viewModel.state.canSubmit)

        // After selecting to
        viewModel.send(.selectToAccount(to))
        XCTAssertFalse(viewModel.state.canSubmit)

        // After entering amount
        viewModel.send(.updateAmount(100))
        XCTAssertTrue(viewModel.state.canSubmit)
    }

    func testTransferCompletedResetsForm() {
        // Given
        let from = Account(id: "1", name: "From")
        let to = Account(id: "2", name: "To")
        let transaction = Transaction(id: "tx1", amount: 100)

        viewModel.send(.selectFromAccount(from))
        viewModel.send(.selectToAccount(to))
        viewModel.send(.updateAmount(100))

        // When
        viewModel.send(.transferCompleted(transaction))

        // Then
        XCTAssertNil(viewModel.state.selectedFrom)
        XCTAssertNil(viewModel.state.selectedTo)
        XCTAssertEqual(viewModel.state.amount, 0)
        XCTAssertEqual(viewModel.state.lastTransaction, transaction)
        XCTAssertFalse(viewModel.state.isLoading)
    }
}

Testing Effects (Asynchronous)

final class TransferViewModelEffectTests: XCTestCase {

    var viewModel: TransferViewModel!
    var mockTransferUseCase: MockTransferUseCase!
    var mockAccountsUseCase: MockGetAccountsUseCase!

    override func setUp() {
        super.setUp()
        mockTransferUseCase = MockTransferUseCase()
        mockAccountsUseCase = MockGetAccountsUseCase()
        viewModel = TransferViewModel(
            transferUseCase: mockTransferUseCase,
            getAccountsUseCase: mockAccountsUseCase
        )
    }

    func testLoadAccountsEffectCallsUseCase() async {
        // Given
        let expectedAccounts = [
            Account(id: "1", name: "Main"),
            Account(id: "2", name: "Savings")
        ]
        mockAccountsUseCase.accountsToReturn = expectedAccounts

        // When
        viewModel.send(.loadAccounts)

        // Wait for effect
        await Task.yield()
        try? await Task.sleep(for: .milliseconds(50))

        // Then
        XCTAssertTrue(mockAccountsUseCase.executeCalled)
        XCTAssertEqual(viewModel.state.accounts, expectedAccounts)
        XCTAssertFalse(viewModel.state.isLoading)
    }

    func testSubmitTransferEffectSuccess() async {
        // Given
        let from = Account(id: "1", name: "From")
        let to = Account(id: "2", name: "To")
        let expectedTransaction = Transaction(id: "tx1", amount: 100)

        mockTransferUseCase.transactionToReturn = expectedTransaction

        viewModel.send(.selectFromAccount(from))
        viewModel.send(.selectToAccount(to))
        viewModel.send(.updateAmount(100))

        // When
        viewModel.send(.submitTransfer)

        // Wait for effect
        await Task.yield()
        try? await Task.sleep(for: .milliseconds(50))

        // Then
        XCTAssertTrue(mockTransferUseCase.executeCalled)
        XCTAssertEqual(mockTransferUseCase.lastFromId, "1")
        XCTAssertEqual(mockTransferUseCase.lastToId, "2")
        XCTAssertEqual(mockTransferUseCase.lastAmount, 100)
        XCTAssertEqual(viewModel.state.lastTransaction, expectedTransaction)
    }

    func testSubmitTransferEffectFailure() async {
        // Given
        let from = Account(id: "1", name: "From")
        let to = Account(id: "2", name: "To")

        mockTransferUseCase.errorToThrow = TransferError.insufficientFunds

        viewModel.send(.selectFromAccount(from))
        viewModel.send(.selectToAccount(to))
        viewModel.send(.updateAmount(100))

        // When
        viewModel.send(.submitTransfer)

        // Wait for effect
        await Task.yield()
        try? await Task.sleep(for: .milliseconds(50))

        // Then
        XCTAssertEqual(viewModel.state.error, .insufficientFunds)
        XCTAssertNil(viewModel.state.lastTransaction)
        XCTAssertFalse(viewModel.state.isLoading)
    }

    func testActionSequenceInOrder() async {
        // Given
        var receivedActions: [TransferViewModel.Action] = []

        let expectedAccounts = [Account(id: "1", name: "Main")]
        mockAccountsUseCase.accountsToReturn = expectedAccounts

        // When
        viewModel.send(.loadAccounts)

        // Immediately after send
        XCTAssertTrue(viewModel.state.isLoading) // Synchronous

        await Task.yield()
        try? await Task.sleep(for: .milliseconds(50))

        // After effect completes
        XCTAssertFalse(viewModel.state.isLoading)
        XCTAssertEqual(viewModel.state.accounts.count, 1)
    }
}

// MARK: - Mocks

final class MockTransferUseCase: TransferMoneyUseCase {
    var executeCalled = false
    var lastFromId: AccountID?
    var lastToId: AccountID?
    var lastAmount: Decimal?

    var transactionToReturn: Transaction?
    var errorToThrow: TransferError?

    override func execute(from: AccountID, to: AccountID, amount: Decimal) async throws -> Transaction {
        executeCalled = true
        lastFromId = from
        lastToId = to
        lastAmount = amount

        if let error = errorToThrow {
            throw error
        }
        return transactionToReturn ?? Transaction(id: "mock", amount: amount)
    }
}

final class MockGetAccountsUseCase: GetAccountsUseCase {
    var executeCalled = false
    var accountsToReturn: [Account] = []
    var errorToThrow: Error?

    override func execute() async throws -> [Account] {
        executeCalled = true
        if let error = errorToThrow {
            throw error
        }
        return accountsToReturn
    }
}

Boilerplate - My Honest Assessment

Let's be honest: this approach adds boilerplate. For each ViewModel you write:

  • State struct,
  • Action enum,
  • reduce() method,
  • handleEffect() method

Is It Worth It?

SituationIs the boilerplate worth it?
Simple screen (list, details)❌ No - plain @Observable is enough
Form with validation✅ Yes - explicit state transitions
Screen with many states✅ Yes - easier to understand
Financial/medical application✅ Definitely - audit trail
MVP/prototype❌ No - time matters
5+ year project✅ Yes - maintainability

Frequently Asked Questions

1. How is this different from regular MVVM with enum Action?

In classic MVVM with actions, mutations are still scattered inside the handler:

// ❌ Pseudo-reducer - mutations in async context
func handle(_ action: Action) {
    switch action {
    case .loadAccounts:
        isLoading = true  // Direct mutation
        Task {
            accounts = await fetch()  // Another mutation
            isLoading = false         // And another one
        }
    }
}

In the true reducer pattern:

  1. reduce() is a PURE FUNCTION - no side effects,
  2. All mutations are SYNCHRONOUS,
  3. Async effects are SEPARATED and trigger subsequent actions,
  4. State is IMMUTABLE (we copy, modify the copy, return it),

2. Isn't this over-engineering for simple screens?

Yes - that's why you should use it only where necessary. -> See the boilerplate section. <-

3. What about performance?

The reducer operates on small structs per feature - in practice, the overhead is negligible compared to SwiftUI rendering.


Migration - How to Introduce This Approach

You can migrate incrementally, ViewModel by ViewModel:

  1. Start with the most problematic ViewModels - those with the most bugs,
  2. Extract State struct - group properties into a single State,
  3. Define Action enum - for each public method, create an action,
  4. Write reduce() - move synchronous mutation logic there,
  5. Extract effects - move asynchronous operations to a separate method,
  6. Replace method calls with send()

Summary

The reducer pattern is a powerful tool - but a tool that should be used in the right place. Key takeaways:

  1. Apply reducer only where needed - not everywhere,
  2. Domain layer remains clean - business logic knows nothing about reducers,
  3. No vendor lock-in - it's your code, your rules,
  4. Easy migration - you can introduce this approach incrementally,
  5. Simple debugging - single entry point for all changes,
  6. Scale the reducer - split it into smaller functions when it grows,
  7. Respect structured concurrency - cancel tasks, check isCancelled
Rafał Dubiel

Rafał Dubiel

Senior iOS Developer z ponad 8-letnim doświadczeniem w tworzeniu aplikacji mobilnych. Pasjonat czystej architektury, Swift i dzielenia się wiedzą ze społecznością.

Udostępnij artykuł:

MVVM + Reducer Pattern | FractalDev Blog