iOS Development

MVVM + Reducer Pattern

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

Po ponad 8 latach w branży i doświadczeniu z różnymi architekturami - od prostego MVC, przez MVVM, VIPER, TCA aż po Clean Architecture doszedłem do wniosku, który może wydać się kontrowersyjny:

Najlepsza architektura to ta, którą kontrolujesz, rozumiesz i możesz dostosować do potrzeb projektu.

Ten artykuł nie jest kolejnym „TCA vs Clean architecture". To propozycja pragmatycznego podejścia, które łączy przewidywalność reducer pattern z MVVM bez przywiązywania się do żadnego frameworka.


TL;DR

  • Klasyczny MVVM w iOS nie skaluje się dobrze - ViewModele szybko zamieniają się w niekontrolowane zbiory mutacji,
  • Reducer pattern rozwiązuje ten problem, bo wymusza jedno źródło prawdy, jawne akcje i przewidywalne zmiany stanu,
  • Nie potrzebujesz TCA - reducer to koncept, nie framework,
  • Reducer może żyć lokalnie w ViewModelu, bez ingerencji w warstwę domenową i bez vendor lock-in,
  • Asynchroniczne operacje powinny być effectami, a nie miejscem mutacji stanu,
  • Nawigacja jako część stanu świetnie pasuje do deklaratywnego NavigationStack,
  • To podejście dodaje boilerplate, ale radykalnie poprawia debugowanie, testowalność i przewidywalność,
  • Używaj go tam, gdzie stan jest złożony - nie wszędzie.

Problem, który rozwiązujemy

Zanim przejdziemy do rozwiązania, zdefiniujmy problem. ViewModele w klasycznym MVVM często ewoluują w nieprzewidywalny sposób:

// Typowy ViewModel po kilku miesiącach rozwoju
@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  // mutacja #1
        error = nil       // mutacja #2
        do {
            accounts = try await api.fetchAccounts() // mutacja #3
            isLoading = false  // mutacja #4
        } catch {
            self.error = error  // mutacja #5
            isLoading = false   // mutacja #6
        }
    }

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

    func submitTransfer() async {
        isLoading = true  // mutacja #9
        // ... kolejne mutacje rozproszone po metodzie
    }

    // ... 20 innych metod z rozproszonymi mutacjami
}

Co jest nie tak z tym kodem?

  • Rozproszone mutacje - stan zmienia się w wielu miejscach, trudno prześledzić flow,
  • Implicit state transitions - nie jest jasno określone „co spowodowało tę zmianę",
  • Trudne debugowanie - breakpointy trzeba stawiać w wielu miejscach,
  • Race conditions - asynchroniczne mutacje mogą się nakładać,
  • Słaba audytowalność - w aplikacjach np. bankowych musisz wiedzieć, co i kiedy się zmieniło.

Reducer Pattern - koncept, nie framework

Reducer pattern to nie wynalazek TCA czy Reduxa. To prosty koncept z programowania funkcyjnego:

newState = reduce(currentState, action)

Kluczowe cechy:

  • Pure function - dla tych samych inputów zawsze ten sam output,
  • Single source of truth - cały state w jednym miejscu,
  • Explicit transitions - każda zmiana to konkretna akcja,
  • Debuggable - jeden punkt wejścia dla wszystkich zmian.

Problem z TCA nie leży w samym reducer pattern, ale w tym, że framework narzuca całą architekturę, wymaga studiowania rozbudowanego API i tworzy vendor lock-in.


Hybrid Approach: MVVM + Local Reducer

Propozycja jest prosta: weźmy reducer z TCA i dodajmy go do MVVM. Reducer żyje TYLKO w warstwie prezentacji. Warstwa domeny pozostaje czysta, niezależny od jakichkolwiek wzorców czy frameworków.


Implementacja krok po kroku

Krok 1: Warstwa domenowa (tutaj przykład z Clean Architecture) - czysta logika biznesowa

Zacznijmy od warstwy, która nie zmienia się niezależnie od tego, czy używamy reducerów, MVVM czy czegokolwiek innego:

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
        )
    }
}

Zauważ: Use Case nie wie nic o UI, reducerach ani SwiftUI. To czysta logika biznesowa, którą możesz przetestować w izolacji i potencjalnie współdzielić między platformami.

Krok 2: ViewModel z wbudowanym reducerem (iOS 17+ Observation)

Od iOS 17 mamy nowy Observation framework z makrem @Observable, który zastępuje @Published i jest znacznie lżejszy. Nie potrzebujesz już @Published na state - wystarczy zwykłe pole, a SwiftUI automatycznie śledzi zmiany.

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))
        }
    }
}

Uwaga o Structured Concurrency

W powyższym kodzie używamy kilku ważnych operacji:

  1. currentTask?.cancel() przed nowym Task - zapobiega race conditions gdy user szybko klika,
  2. Task.isCancelled checks - respektujemy anulowanie w każdym punkcie await,
  3. [weak self] - zapobiega retain cycles,
  4. deinit { currentTask?.cancel() } - czyszczenie przy dealokacji.

Dla bardziej złożonych scenariuszy (np. wiele równoległych efektów) rozważ użycie TaskGroup lub dedykowanego 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()
        }
    }
}

Skalowanie Reducera - unikanie „Massive Reducer"

Dla prostych ekranów płaski switch w reduce() jest OK. Ale gdy masz 20+ akcji, reducer staje się nieczytelny - analogiczny problem do „Massive ViewModel".

Rozwiązanie: Kompozycja reducerów

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
}

Zalety kompozycji:

  • Każdy sub-reducer odpowiada za jeden aspekt stanu,
  • Łatwiejsze testowanie jednostkowe,
  • Czytelniejszy kod - otwierasz reduceError() i widzisz całą logikę błędów,
  • Można wyciągnąć reducery do osobnych plików dla bardzo dużych ViewModeli

Alternatywa: Grupowanie akcji

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 z Custom Bindings

Dla dłuższych formularzy ręczne tworzenie Binding z send() staje się uciążliwe. Oto pattern z custom binding:

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)
    }
}

Generyczny Binding Helper

Dla jeszcze czystszego kodu możesz stworzyć 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)) }
        )
    }
}

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

Nawigacja jako część stanu

Przez długi czas standardem były koordynatory, które imperatywnie sterowały nawigacją. Wraz z popularyzacją architektur opartych o Reducer i SwiftUI, coraz więcej zespołów decyduje się na modelowanie nawigacji jako elementu globalnego stanu. To podejście lepiej pasuje do deklaratywnego charakteru NavigationStack, który renderuje widok wyłącznie na podstawie aktualnego stanu.

Opcja 1: Nawigacja w ViewModelu (lokalne)

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

        // ... inne przypadki
        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)
                    }
                }
        }
    }
}

Opcja 2: Globalny AppReducer z NavigationPath()

Dla aplikacji z wieloma ścieżkami przejść można użyć NavigationPath jako część globalnego stanu:

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

    struct AppState: Equatable {
        var navigationPath = NavigationPath()
        var transferState: TransferState = .init()
        var settingsState: SettingsState = .init()
        // ... inne 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)
    }
}

Zalety globalnego store:

  • Jeden NavigationPath dla całej aplikacji,
  • Łatwy deep linking,
  • Możliwość „pop to root" z dowolnego miejsca,
  • Spójny z reducer pattern,

Wady:

  • Więcej boilerplate,
  • Wszystkie routes muszą być Hashable,
  • Trudniejsze testowanie jednostkowe funkcjonalności w izolacji,
  • W większej aplikacji szybko robi się „god object”, gdzie wszystkie funkcjonalności muszą znać globalne route’y.

Przed i po - rozszerzone porównanie

Aspekt❌ Klasyczny MVVM✅ MVVM + Reducer🟡 TCA
MutacjeRozproszone po metodachWszystkie w reduce()Wszystkie w reduce()
FlowTrudno prześledzićAction → State explicitAction → State explicit
DebuggingBreakpointy wszędzieJeden breakpointJeden breakpoint + narzędzia
Race conditionsMożliweZnacznie ograniczone i łatwiejsze do kontroliEffect system
Audit trailBrakLogowanie akcjiWbudowane
BoilerplateMinimalnyŚredni (State + Action)Wysoki (Reducer, Store, Effect)
Learning curveNiskaŚredniaWysoka
Vendor lock-inBrakBrakSilny
NavigationDowolneDowolne lub w StateWbudowany system
TestowanieMocki wszędzieProste unit testyŚwietne, ale wymaga nauki
OnboardingSzybkiSzybkiWymaga szkolenia
WersjonowanieBez zmianBez zmianBreaking changes między wersjami

Debugging - jeden punkt wejścia

Jedną z największych zalet tego podejścia jest łatwość debugowania. Możesz dodać proste logowanie:

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

W konsoli widzisz dokładnie, co się dzieje:

🔄 [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

Testowanie

Testowanie Reducera (synchroniczne, deterministyczne)

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 (synchroniczne)

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

        // When
        viewModel.send(.loadAccounts)

        // Then - natychmiast, bez 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)
    }
}

Testowanie Effects (asynchroniczne)

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) // Synchroniczne

        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 - moja uczciwa ocena

Przyznajmy wprost: to podejście dodaje boilerplate. Dla każdego ViewModelu piszesz:

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

Czy warto?

SytuacjaBoilerplate się opłaca?
Prosty ekran (lista, szczegóły)❌ Nie - zwykły @Observable wystarczy
Formularz z walidacją✅ Tak - explicit state transitions
Ekran z wieloma stanami✅ Tak - łatwiejsze zrozumienie
Aplikacja finansowa/medyczna✅ Zdecydowanie - audit trail
MVP/prototyp❌ Nie - czas ma znaczenie
Projekt 5+ lat✅ Tak - maintainability

Najczęstsze pytania

1. Czym to się różni od zwykłego MVVM z enum Action?

W klasycznym MVVM z akcjami mutacje wciąż są rozproszone wewnątrz handlera:

// ❌ Pseudo-reducer - mutacje w async kontekście
func handle(_ action: Action) {
    switch action {
    case .loadAccounts:
        isLoading = true  // Mutacja bezpośrednia
        Task {
            accounts = await fetch()  // Kolejna mutacja
            isLoading = false         // I jeszcze jedna
        }
    }
}

W prawdziwym reducer pattern:

  1. reduce() jest PURE FUNCTION - nie ma side effects,
  2. Wszystkie mutacje są SYNCHRONICZNE,
  3. Async effects są ODDZIELONE i wywołują kolejne akcje,
  4. State jest IMMUTABLE (kopiujemy, modyfikujemy kopię, zwracamy),

2. Czy to nie jest over-engineering dla prostych ekranów?

Tak - dlatego używaj tego tylko tam gdzie trzeba. -> Patrz sekcja o boilerplate. <-

3. Co z wydajnością?

Reducer operuje na małych structach per feature - w praktyce narzut jest pomijalny wobec renderowania SwiftUI.


Migracja - jak wprowadzić to podejście

Możesz migrować inkrementalnie, ViewModel po ViewModelu:

  1. Zacznij od najbardziej problematycznych ViewModeli - tych z wieloma bugami,
  2. Wydziel State struct - zgrupuj properties w jeden State,
  3. Zdefiniuj Action enum - dla każdej metody publicznej stwórz akcję,
  4. Napisz reduce() - przenieś logikę synchronicznych mutacji,
  5. Wydziel effects - asynchroniczne operacje przenieś do osobnej metody,
  6. Zamień wywołania metod na send()

Podsumowanie

Reducer pattern to potężne narzędzie - ale narzędzie, które powinno być użyte w odpowiednim miejscu. Kluczowe wnioski:

  1. Reducer stosujemy tylko tam, gdzie potrzeba - nie wszędzie,
  2. Warstwa domenowa pozostaje czysta - logika biznesowa nie wie o reducerach,
  3. Brak vendor lock-in - to Twój kod, Twoje zasady,
  4. Łatwa migracja - możesz wprowadzać to podejście inkrementalnie,
  5. Prostota debugowania - jeden punkt wejścia dla wszystkich zmian,
  6. Skaluj reducer - podziel go na mniejsze funkcje gdy się rozrasta,
  7. Respektuj 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