---
title: 'Unidirectional Data Flow'
source: 'https://academia.sh/en/courses/architecture-testing-and-release/unidirectional-data-flow'
course: 'Architecture, Testing and Release'
language: en
updated: '2026-10-21T05:03:07+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Describes the roles of state, action and reducer in unidirectional data flow'
    - 'Builds the reducer as a pure function and applies it to two versions with the same action sequence'
    - 'Measures that a check comparing the resulting state catches the reducer fault'
    - 'Shows that taking the recorded action and state sample from the faulty reducer inverts the check'
---

# Unidirectional Data Flow

Keeping state in a single central store that changes only through actions and a reducer, and measuring the separation of checks by feeding the same action sequence to two reducers.

In the second lesson we took navigation out of the screen and saw that the target of the check changed. Now we look at the state the screen depends on. On a screen, the cart can change from several places: the add button, the remove button, a network response, a notification. If each place changes the state in its own way, you have to walk every path to say why a value is what it is.

Unidirectional data flow reduces these paths to one. The state lives in a single central store. The screen reads the state and, when something happens, sends an action to the store; the store hands the action to a reducer, and the reducer produces the new state from the old one. The flow always goes in the same direction: state, screen, action, reducer, new state. This lesson's fault is in the reducer: the "remove product" action deletes the line completely instead of decreasing the quantity by one. The core comes in verbatim, as in the earlier lessons.

```swift
// Separation.swift — classifies whether a check tells the correct version from the faulty one
struct Contrast<Output: CustomStringConvertible> {
    let label: String
    let correct: Output
    let faulty: Output
    let passesCorrect: Bool
    let passesFaulty: Bool

    var separation: String {
        switch (passesCorrect, passesFaulty) {
        case (true, false): return "separating"
        case (true, true): return "blind"
        case (false, false): return "noisy"
        case (false, true): return "inverted"
        }
    }

    func describe() -> String {
        let onCorrect = passesCorrect ? "pass" : "fail"
        let onFaulty = passesFaulty ? "pass" : "fail"
        print("\(label) | correct=\(correct)/\(onCorrect) faulty=\(faulty)/\(onFaulty) -> \(separation)")
        return separation
    }
}

func contrast<Output>(_ label: String, correct: Output, faulty: Output, check: (Output) -> Bool) -> Contrast<Output> {
    Contrast(label: label, correct: correct, faulty: faulty, passesCorrect: check(correct), passesFaulty: check(faulty))
}

func countSeparations(_ results: [String]) {
    for name in ["separating", "blind", "noisy", "inverted"] {
        print("\(name): \(results.filter { $0 == name }.count)")
    }
}
```

In this lesson too, the core is only used. `contrast` applies the same check to the output of two versions and returns one of the four separations.

## State, Action, Reducer

The central store is built from three parts. The **state** is the application's data at that moment: the items in the cart and their quantities. An **action** is a small value that says something happened: "add pen", "remove pen". The **reducer** is the function that takes the old state and an action and gives the new state. The reducer is pure: the same state and the same action always give the same new state, and it writes nothing to the outside.

The platform-dependent part is a thin shell. The store holds the state, passes the action to the reducer and stores the result; the view reads the state and sends actions.

```swift
import SwiftUI

// Store and view — KAYNAK
final class CartStore: ObservableObject {
    @Published private(set) var state = CartState()

    func send(_ action: CartAction) {
        state = reduceCart(state, action)
    }
}

struct CartView: View {
    @ObservedObject var store: CartStore

    var body: some View {
        Button("Add pen") { store.send(.add("pen")) }
    }
}
```

This fragment is not run; the view and the published property depend on the platform, and the lesson claims no output for them. What matters is that the shell makes no decisions. `send` only calls the reducer, and the view only sends actions. All the decisions are in `reduceCart`, and that function is plain Swift. The measurement looks at that function.

## The Reducer's Model

The model defines four things: the state, the action, the correct and faulty reducer, and `replayActions`, which applies a sequence of actions in order. The reducer is a single function; the `faulty` flag selects the faulty version. The fault is in the `remove` branch: the correct version decreases the quantity if it is greater than 1 and deletes the line otherwise; the faulty version always deletes. `Reducer.swift` is a helper file and prints no output.

```swift
// Reducer.swift — cart state, actions and reducer
struct CartItem: Equatable {
    let sku: String
    var quantity: Int
}

struct CartState: Equatable, CustomStringConvertible {
    var items: [CartItem] = []

    var description: String {
        items.isEmpty ? "empty" : items.map { "\($0.sku)x\($0.quantity)" }.joined(separator: ",")
    }
}

enum CartAction {
    case add(String)
    case remove(String)
}

func reduceCart(_ state: CartState, _ action: CartAction, faulty: Bool = false) -> CartState {
    var next = state
    switch action {
    case .add(let sku):
        if let index = next.items.firstIndex(where: { $0.sku == sku }) {
            next.items[index].quantity += 1
        } else {
            next.items.append(CartItem(sku: sku, quantity: 1))
        }
    case .remove(let sku):
        guard let index = next.items.firstIndex(where: { $0.sku == sku }) else { return next }
        if !faulty && next.items[index].quantity > 1 {
            next.items[index].quantity -= 1
        } else {
            next.items.remove(at: index)
        }
    }
    return next
}

func replayActions(_ actions: [CartAction], faulty: Bool) -> CartState {
    actions.reduce(CartState()) { reduceCart($0, $1, faulty: faulty) }
}

func quantity(of sku: String, in state: CartState) -> Int {
    state.items.first(where: { $0.sku == sku })?.quantity ?? 0
}
```

Four checks are applied to two action sequences. The first sequence adds twice and removes once: in the correct version one pen should remain, and in the faulty version none remains. The second sequence adds once and removes once: a product with a quantity of 1 is deleted completely in both versions.

```swift
// main.swift — the same action sequence, two reducers
let twoThenRemove: [CartAction] = [.add("pen"), .add("pen"), .remove("pen")]
let oneThenRemove: [CartAction] = [.add("pen"), .remove("pen")]

let goodState = replayActions(twoThenRemove, faulty: false)
let badState = replayActions(twoThenRemove, faulty: true)
let goodSingle = replayActions(oneThenRemove, faulty: false)
let badSingle = replayActions(oneThenRemove, faulty: true)

let sameState = contrast("compare-state", correct: goodState, faulty: badState) { $0 == CartState(items: [CartItem(sku: "pen", quantity: 1)]) }.describe()
let leftover = contrast("remaining-quantity", correct: quantity(of: "pen", in: goodState), faulty: quantity(of: "pen", in: badState)) { $0 == 1 }.describe()
let notNegative = contrast("quantity-not-negative", correct: quantity(of: "pen", in: goodState), faulty: quantity(of: "pen", in: badState)) { $0 >= 0 }.describe()
let singleLine = contrast("single-quantity-item", correct: goodSingle.items.count, faulty: badSingle.items.count) { $0 == 0 }.describe()

countSeparations([sameState, leftover, notNegative, singleLine])
```

```text
compare-state | correct=penx1/pass faulty=empty/fail -> separating
remaining-quantity | correct=1/pass faulty=0/fail -> separating
quantity-not-negative | correct=1/pass faulty=0/pass -> blind
single-quantity-item | correct=0/pass faulty=0/pass -> blind
separating: 2
blind: 2
noisy: 0
inverted: 0
```

The output was run three times in the sandbox on Swift 6.4 and is identical in all three. Of the four checks, two came out separating and two blind.

`compare-state` compares the resulting state of the same action sequence with the expected state. The correct version gives `penx1`, the faulty version gives `empty`; the second does not meet the expectation. `remaining-quantity` reduces the same sequence to a single number: the quantity of the pen should be 1, and in the faulty version it comes out 0. Both are separating. Their common cause is this: both look at what the fault changes, that is, at the state the reducer produces.

`quantity-not-negative` looks at a property the fault does not break. In the faulty version the pen is deleted and the quantity reads 0; 0 is not less than zero either. The check passes on both versions and is blind. `single-quantity-item`, on the other hand, shows that the fault never appears in the second sequence: removing a product with a quantity of 1 deletes the product in both versions, and while looking at the row count the check sees both give 0. The check is blind for this sequence, and it is blind because the input hides the fault.

## What the Flow Adds to Checking

What makes this measurement easy is the flow itself. Because the reducer is pure, the same action sequence can be given to two versions and the two resulting states can be compared exactly. The screen or the network does not need to get involved. In an application that changes the state from many places, the same comparison would require driving each path separately.

Also, because the state is a single value, what the check looks at can be chosen: the whole state, one field, or a derived number. Three of the four checks read the same two states in different ways; only two saw the fault. How strong a check is may depend on how much of the state it reads. `compare-state` reads the whole state, and for this reason it stays separating even when the fault shows up in every field, not just one. `remaining-quantity` reads only one field; it would have been blind if the fault had not touched that field.

The action sequence is an input as well. The two sequences answered the same fault differently: the same check was in both, and only the sequence changed. This is the action-level counterpart of the ten percent and fifteen percent example in the first lesson. A good reducer test tries more than one sequence, and in particular it tries boundary sequences: an empty cart, a product with a quantity of one, consecutive additions of the same product.

## Assumptions

**AR1.** The store and the view stay KAYNAK. Redrawing the view when a real store publishes its state is outside this lesson's scope; the model only compares the state the reducer produces.

**AR2.** The reducer is assumed to be pure: it reads nothing from the outside and writes nothing to the outside. In a real application, side effects (a network request, saving) live in a separate layer outside the reducer, and this lesson does not model them.

**AR3.** The product identifier (`sku`) is a string and maps to an item name. Stock, price and the error returned when a product is not found do not enter the model; a remove action for an item that does not exist leaves the state unchanged.

## The Bounding Measurement

Taking the expected value from a recording is the easiest way to record the fault. Here the recorded sample is taken from the faulty reducer's result for the first sequence: `empty`. The same recorded sample is tried against two separate sequences.

```swift
// main.swift — the recorded action and state sample was taken from the faulty reducer
let recorded = "empty"

let twoThenRemove: [CartAction] = [.add("pen"), .add("pen"), .remove("pen")]
let oneThenRemove: [CartAction] = [.add("pen"), .remove("pen")]

let onTwo = contrast("record-two-adds-one-remove", correct: replayActions(twoThenRemove, faulty: false), faulty: replayActions(twoThenRemove, faulty: true)) { $0.description == recorded }.describe()
let onOne = contrast("record-one-add-one-remove", correct: replayActions(oneThenRemove, faulty: false), faulty: replayActions(oneThenRemove, faulty: true)) { $0.description == recorded }.describe()

countSeparations([onTwo, onOne])
```

```text
record-two-adds-one-remove | correct=penx1/fail faulty=empty/pass -> inverted
record-one-add-one-remove | correct=empty/pass faulty=empty/pass -> blind
separating: 0
blind: 1
noisy: 0
inverted: 1
```

The output is identical across three runs. In the first sequence the recording was taken from the faulty version, so the correct version gives `penx1` and is rejected, while the faulty version gives `empty` and is accepted. This is an inverted check, and it lets the faulty reducer pass green. In the second sequence both versions give `empty`, so the same recording accepts both and the check stays blind. The same recorded sample is inverted on one sequence and blind on the other. A recorded sample is meaningful only when it is taken from the correct version; if the recording was once taken from a faulty output, then when the reducer is fixed every check turns red, and the person looking at the red may renew the recording instead of fixing the check, which makes the fault permanent.

## Summary

- In unidirectional data flow the state lives in a central store, change arrives only through an action, and the reducer produces the new state from the old one.
- The reducer is a pure function; the same action sequence can be given to two versions and the two resulting states can be compared.
- Checks that compare the resulting state and the remaining quantity catch the faulty reducer: of four checks, two are separating and two are blind.
- A check that looks at a property the fault does not touch, and a check that looks at a sequence in which the fault does not appear, stay blind.
- If the recorded action and state sample is taken from the faulty reducer, the same check is inverted on one sequence and blind on the other.
- This lesson's separation: **separating**.

## Next Step

The reducer was easy to measure because it is pure; but in a real application part of the state comes from the outside. In the fourth lesson, Dependency Injection will cover giving external dependencies such as a network client to the screen or the store from outside. There the fault will be in a client: when an empty response arrives, it will clear the previous list instead of keeping it, and a check tied to the real network will stay behind in both versions.
