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:
currentTask?.cancel()before new Task - prevents race conditions when user clicks rapidly,Task.isCancelledchecks - we respect cancellation at every await point,[weak self]- prevents retain cycles,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 |
|---|---|---|---|
| Mutations | Scattered across methods | All in reduce() | All in reduce() |
| Flow | Hard to trace | Action → State explicit | Action → State explicit |
| Debugging | Breakpoints everywhere | Single breakpoint | Single breakpoint + tools |
| Race conditions | Possible | Significantly limited and easier to control | Effect system |
| Audit trail | None | Action logging | Built-in |
| Boilerplate | Minimal | Medium (State + Action) | High (Reducer, Store, Effect) |
| Learning curve | Low | Medium | High |
| Vendor lock-in | None | None | Strong |
| Navigation | Any | Any or in State | Built-in system |
| Testing | Mocks everywhere | Simple unit tests | Excellent, but requires learning |
| Onboarding | Fast | Fast | Requires training |
| Versioning | No changes | No changes | Breaking 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:
Statestruct,Actionenum,reduce()method,handleEffect()method
Is It Worth It?
| Situation | Is 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:
reduce()is a PURE FUNCTION - no side effects,- All mutations are SYNCHRONOUS,
- Async effects are SEPARATED and trigger subsequent actions,
- 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:
- Start with the most problematic ViewModels - those with the most bugs,
- Extract State struct - group properties into a single State,
- Define Action enum - for each public method, create an action,
- Write
reduce()- move synchronous mutation logic there, - Extract effects - move asynchronous operations to a separate method,
- 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:
- Apply reducer only where needed - not everywhere,
- Domain layer remains clean - business logic knows nothing about reducers,
- No vendor lock-in - it's your code, your rules,
- Easy migration - you can introduce this approach incrementally,
- Simple debugging - single entry point for all changes,
- Scale the reducer - split it into smaller functions when it grows,
- Respect 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ł: