ANTIMATERIAL
Back to all posts
by Philip Kluz

Why Your ViewModel is Lying to You (And What to Do About It)

Build a minimal reducer-effect system from scratch to understand how unidirectional data flow makes side effects testable - and why production apps need more than just the basics.

Why Your ViewModel is Lying to You (And What to Do About It)

You've written the test. The mock returns the expected data. The assertion passes. Ship it.

Three weeks later, the same code crashes in production. The API returned data in a different order. A user tapped a button twice. A background refresh happened mid-animation.

The test passed because it tested your code. It didn't test what your code does to the world - or what the world does back.

This is the side-effect problem. And it's why unidirectional data flow (UDF) architectures exist.

The Core Insight

Every app does three things:

  1. Holds state - what the user sees
  2. Responds to actions - user taps, API responses, timers
  3. Produces effects - network calls, disk writes, analytics

Most architectures tangle these together. A ViewModel fetches data, updates state, and triggers navigation - all in one method. Testing requires mocking half the system.

Reducer based UDF systems separate them:

                    ┌───────────────────────────────┐
                    │            REDUCER            │
                    │  ┌─────────┐    ┌─────────┐   │
      Action        │  │  State  │    │ Effect  │   │
    ┌──────────────►│  └────┬────┘    └────┬────┘   │
    │               └───────┼──────────────┼────────┘
    │                       │              │
    │                       ▼              │ execute
    │               ┌───────────────┐      │
    │               │   NEW STATE   │      │
    │               └───────┬───────┘      │
    │                       |              ▼
    │         observe       |      ┌───────────────┐
    │ ┌ - - - - - - - - - - ┘      │  ASYNC WORK   │
    │ ▼                            │  (API, ...)   │
┌───┴───┐                          └───────┬───────┘
│ VIEW  │◄ - - - - - - - - - - - - - - - - ┘
└───────┘                 Action

The reducer is a pure function. Given the same state and action, it always returns the same result (aka “Idempotence"). Effects are descriptions of work, not the work itself.

This separation is the entire point.

Building a Minimal Reducer Protocol

Let's build this from scratch. We want any type - controller, view model, coordinator - to adopt a protocol that enforces this pattern. Why? Because protocol conformance is a contract. If your type conforms to Reducer, it must expose state and handle actions through a pure function. That constraint is what makes testing trivial: inject state, call reduce, assert on output. No mocks required.

protocol Reducer {
    associatedtype State
    associatedtype Action
    
    var state: State { get }
    
    func send(_ action: Action)
}

Simple. But where's the reducer bit? Let's add the core function:

protocol Reducer {
    associatedtype State
    associatedtype Action
    
    var state: State { get set }
    
    func send(_ action: Action)
    func reduce(action: Action) -> (State, Effect<Action>)
}

The primary interface for the user of the reducer is the send(_:) function. Internally it performs an immediate redirect to reduce(_:) and handles the return values. reduce(_:) itself receives an action, and returns the newly computed state as well as an effect.

Defining Effects

Consider a typical ViewModel method:

func loginTapped() {
    analytics.track("login_attempted")           // side effect 1
    isLoading = true							 // state mutation 1
    api.login(email: email, password: password)  // side effect 2
        .sink { [weak self] result in	
            self?.isLoading = false				 // state mutation 2
            self?.handleLoginResult(result)		 // side effect 3
        }
        .store(in: &cancellables)
}

Two side effects fire immediately when you call this method. Testing it requires mocking both analytics and api. The method does things - it reaches out into the world.

In a reducer, we flip this. Instead of executing effects, we return descriptions of them:

struct Effect<Action> {
    let run: (@escaping (Action) -> Void) async -> Void
    
    static var none: Effect {
        Effect { _ in }
    }

    static func run(_ operation: @escaping (@escaping (Action) -> Void) async -> Void) -> Effect {
        Effect(run: operation)
    }
}

That nested closure signature looks intimidating. Here's why it's shaped that way:

  PHASE 1: Creation                    PHASE 2: Execution
  ──────────────────                   ───────────────────

          ┌──────────┐                    ┌──────────┐
          │ Reducer  │                    │  System  │
          └────┬─────┘                    └────┬─────┘
               │ returns                       │ calls
               ▼                               ▼
  ┌────────────────────────┐    ┌─────────────────────────────┐
  │ Effect.run { send in   │    │ effect.run { action in      │
  │   ...                  │    │   self.send(action)         │
  │ }                      │    │ }                           │
  └────────────────────────┘    └──────────────┬──────────────┘
                                               │ executes
  - - - - - - - - - - - - - - - - - - - - - - -│- - - - - - - -
                   later...                    ▼
                                        ┌─────────────┐
  ┌──────────┐   sends back             │ Async Work  │
  │ Reducer  │◄ - - - - - - - - - - - - │ (API call)  │
  └──────────┘.        Action           └──────┬──────┘
                   (.dataLoaded)               │ produces
                                               ▼
                                        ┌─────────────┐
                                        │   Action    │
                                        │(.dataLoaded)│
                                        └─────────────┘

The effect is a recipe: "when you run me, I'll do async work and call back with actions." The system decides when and how to run it. This inversion of control is what makes effects testable - you can inspect the recipe without executing it.

View Models as Reducers

Here's a concrete implementation:

final class FeatureViewModel: Reducer {
    struct State {
        var count: Int = 0
        var isLoading: Bool = false
    }
    
    enum Action {
        case incrementTapped
        case decrementTapped
        case loadTapped
        case dataLoaded(Int)
    }
    
    var state: State
    
    init(state: State = State()) {
        self.state = state
    }
    
    func send(_ action: Action) {
        let (newState, effect) = reduce(action: action)
        state = newState
        
        // ⚠️ Note this unstructured Task - we'll revisit why it's problematic.
        Task {
            await effect.run { [weak self] action in
                self?.send(action)
            }
        }
    }
    
    func reduce(action: Action) -> (State, Effect<Action>) {
        var state = state
        
        switch action {
        case .incrementTapped:
            state.count += 1
            return (state, .none)
            
        case .decrementTapped:
            state.count -= 1
            return (state, .none)
            
        case .loadTapped:
            state.isLoading = true
            return (state, .run { send in
                try? await Task.sleep(for: .seconds(1))
                send(.dataLoaded(42))
            })
            
        case .dataLoaded(let value):
            state.isLoading = false
            state.count = value
            return (state, .none)
        }
    }
}

The action enum is flat and simple. Each case represents something that happened: user tapped a button, data arrived. In the future we will also explore ways to deal with “action creep” and “reducer bloat”.

Why This Is Testable

Testing becomes trivial:

func testIncrementTapped() {
    let vm = FeatureViewModel(state: .init(count: 0))
    
    let (newState, _) = vm.reduce(action: .incrementTapped)
    
    assert(newState.count == 1)
}

func testLoadReturnsEffect() {
    let vm = FeatureViewModel(state: .init(count: 0, isLoading: false))
    
    let (newState, effect) = vm.reduce(action: .loadTapped)
    
    assert(newState.isLoading == true)
    // effects are data - we can inspect them, ignore them, run them ourselves, or assert over them.
}

No mocks. No async test helpers. The reducer is pure. The effect is just data we can examine.

⚠️ A note on what we're testing: We're calling reduce(_:) directly here, not send(_:). That's a deliberate simplification. In production, tests should go through send(_:) - fire an action, wait, then assert on the resulting state. Why? Because send(_:) is the real interface. It handles the effect execution, the state assignment, the full cycle. Testing reduce(_:) directly is like testing a private method: it works, but it couples your tests to implementation details.

The problem is that send(_:) returns Void and effects run in unstructured Tasks. There's no way to know when effects complete. A proper test harness needs to intercept that send(_:) callback inside effects, track in-flight work, and let tests await completion. That's exactly what TCA's TestStore provides - and what we'll build toward in this series. For now, testing reduce(_:) directly demonstrates the concept of pure-function testability without the machinery.

Why This Isn't Production Ready (Yet)

What we've built demonstrates the pattern. It's not safe for production. Here's why:

No action queue. Remember that unstructured Task in send(_:)? Every effect spawns one. Now imagine a search field where each keystroke triggers an API call. The user types "swift" - that's five effects, five tasks, five potential responses arriving in any order. Task for "s" might complete after task for "swift". Without a queue to serialize actions, your state updates become a race condition. This isn't unique to reducers - traditional ViewModels have the exact same problem. The difference is that reducer architectures force you to confront it by making the effect boundary explicit.

No main thread guarantee. The effect runs on an arbitrary Task. If send(_:) updates state that SwiftUI observes, we've violated the main actor requirement.

No cancellation. Start a network request, leave the screen, the request completes and sends an action to a dead view model. At best, wasted work. At worst, a crash or state corruption.

No effect identity. If the user taps "load" twice, we fire two requests. We have no way to cancel the first or deduplicate.

No composition. Real apps have dozens of features. How do child features communicate with parents? How do we scope and combine reducers?

No exhaustive testing. Our test verified the state change, but didn't assert that no other effects were returned. In a real test, we want guarantees that unexpected effects don't fire.

Production systems like The Composable Architecture solve all of these:

  • Actions queue through an executor that guarantees serial processing
  • State mutations happen on the main actor
  • Effects are cancellable with explicit identifiers
  • TestStore fails if you don't handle every effect and action

However, in this series, we’ll explore how to build a minimum viable effect system by ourselves. While we will diverge from TCA concepts here and there for illustrative purposes here and there but at large the ideas are the same.

The Payoff

When side effects are controlled:

  • Tests are deterministic. No flaky async timing issues.
  • State is predictable. Given action A in state S, you always get state S'.
  • Debugging is straightforward. Log every action. Replay to reproduce bugs.
  • Refactoring is safe. Change implementation, tests still pass.

The upfront investment in structure pays compound interest in maintainability.

Next Steps

This is Part 1 of Building Your Own Effect System - a series where we'll tackle each of the problems above and build something approaching production-ready.

Coming up:

  • Part 2: The Action Queue - Serializing effects so state updates are deterministic
  • Part 3: The Execution Context - Guaranteeing UI-safe state mutations
  • Part 4: Cancellation & Effect Identity - Cleaning up in-flight work
  • Part 5: More on Effects - What other types of effects do we need?
  • Part 6: Integration - Combining reducers without losing your mind

Your challenge until then: Our Effect type only supports a single async operation. What if reduce(_:) needs to fire two effects - say, an analytics call and an API request? Extend Effect to support combining multiple effects into one. Hint: you'll want a static merge(_ effects: Effect...) function.

If you want the battle-tested version now, study The Composable Architecture. It solves everything we'll build in this series - and then some.

The pattern isn't complex but it is a bit strange at first. Don’t let that fool you. Give it a spin!

Enjoyed this post?

Back to all posts