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:
currentTask?.cancel()przed nowym Task - zapobiega race conditions gdy user szybko klika,Task.isCancelledchecks - respektujemy anulowanie w każdym punkcie await,[weak self]- zapobiega retain cycles,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 |
|---|---|---|---|
| Mutacje | Rozproszone po metodach | Wszystkie w reduce() | Wszystkie w reduce() |
| Flow | Trudno prześledzić | Action → State explicit | Action → State explicit |
| Debugging | Breakpointy wszędzie | Jeden breakpoint | Jeden breakpoint + narzędzia |
| Race conditions | Możliwe | Znacznie ograniczone i łatwiejsze do kontroli | Effect system |
| Audit trail | Brak | Logowanie akcji | Wbudowane |
| Boilerplate | Minimalny | Średni (State + Action) | Wysoki (Reducer, Store, Effect) |
| Learning curve | Niska | Średnia | Wysoka |
| Vendor lock-in | Brak | Brak | Silny |
| Navigation | Dowolne | Dowolne lub w State | Wbudowany system |
| Testowanie | Mocki wszędzie | Proste unit testy | Świetne, ale wymaga nauki |
| Onboarding | Szybki | Szybki | Wymaga szkolenia |
| Wersjonowanie | Bez zmian | Bez zmian | Breaking 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:
Statestruct,Actionenum,reduce()method,handleEffect()method
Czy warto?
| Sytuacja | Boilerplate 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:
reduce()jest PURE FUNCTION - nie ma side effects,- Wszystkie mutacje są SYNCHRONICZNE,
- Async effects są ODDZIELONE i wywołują kolejne akcje,
- 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:
- Zacznij od najbardziej problematycznych ViewModeli - tych z wieloma bugami,
- Wydziel State struct - zgrupuj properties w jeden State,
- Zdefiniuj Action enum - dla każdej metody publicznej stwórz akcję,
- Napisz
reduce()- przenieś logikę synchronicznych mutacji, - Wydziel effects - asynchroniczne operacje przenieś do osobnej metody,
- 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:
- Reducer stosujemy tylko tam, gdzie potrzeba - nie wszędzie,
- Warstwa domenowa pozostaje czysta - logika biznesowa nie wie o reducerach,
- Brak vendor lock-in - to Twój kod, Twoje zasady,
- Łatwa migracja - możesz wprowadzać to podejście inkrementalnie,
- Prostota debugowania - jeden punkt wejścia dla wszystkich zmian,
- Skaluj reducer - podziel go na mniejsze funkcje gdy się rozrasta,
- Respektuj structured concurrency - cancel tasks, check isCancelled

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ł: