In a reducer-style store, how do you stop a slow, stale response overwriting a newer one: cancellation, tokens and what a cooperative service does?

15.7.1 · L5 · Interview

Predict first

Two searches overlap and the first one is slower. What is applied in each of the four setups?

import Foundation

enum Mode { case naive, cancel, cancelAndGuard }
struct Service: Sendable {
    var cooperative: Bool
    func search(_ q: String) async throws -> [String] {
        let ms = q.count == 2 ? 120 : 20          // the earlier, shorter query is slower
        if cooperative { try await Task.sleep(for: .milliseconds(ms)) }
        else { await withCheckedContinuation { c in DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(ms)) { c.resume() } } }
        return [q + "-result"]
    }
}
enum Action: Sendable { case queryChanged(String), response(String, [String]) }
struct State { var query = ""; var results: [String] = [] }

@MainActor final class Store {
    private(set) var state = State()
    private(set) var applied: [String] = []
    private var inflight: Task<Void, Never>?
    let mode: Mode; let service: Service
    init(mode: Mode, service: Service) { self.mode = mode; self.service = service }
    func send(_ action: Action) {
        switch action {
        case .queryChanged(let q):
            state.query = q
            if mode != .naive { inflight?.cancel() }
            inflight = Task { [service] in
                guard let r = try? await service.search(q) else { return }
                send(.response(q, r))
            }
        case .response(let q, let r):
            if mode == .cancelAndGuard && q != state.query { return }
            applied.append(q); state.results = r
        }
    }
}

@MainActor func run(_ label: String, _ mode: Mode, cooperative: Bool) async {
    let s = Store(mode: mode, service: Service(cooperative: cooperative))
    s.send(.queryChanged("ab"))
    try? await Task.sleep(for: .milliseconds(10))
    s.send(.queryChanged("abc"))
    try? await Task.sleep(for: .milliseconds(400))
    print(label, "applied:", s.applied, "final:", s.state.results)
}
await run("naive:", .naive, cooperative: true)
await run("cancel, cooperative service:", .cancel, cooperative: true)
await run("cancel, service ignores cancellation:", .cancel, cooperative: false)
await run("cancel + guard, service ignores cancellation:", .cancelAndGuard, cooperative: false)
  • naive ["abc", "ab"] stale / cancel with a cooperative service ["abc"] / cancel with a service that ignores it ["abc", "ab"] stale / cancel plus guard ["abc"]
  • Every setup applies only ["abc"]
  • Cancelling always stops the old request, so only the naive setup is stale

Mental model. Two couriers set off for the same address; the slow one with yesterday’s parcel arrives second. Recalling the first courier helps only if they answer the phone; the receptionist must also check the parcel label against today’s order.

An effect is asynchronous, so responses can arrive out of order: the user types “ab”, then “abc”; the “ab” request is slower and lands last, and the screen shows results for a query the user has already left. A reducer being pure does not help, because the reducer happily applies whatever action arrives. In the check the same store runs four ways. Naive: both responses are applied, in the order ["abc", "ab"], and the final state is the stale “ab” result. Cancel the previous effect when a new query starts: with a service that honours cancellation (Task.sleep, URLSession async calls) the old task throws and never sends its response, so only “abc” is applied. The effect is identified by a key (“search”) so a new run replaces the old; that is what the debounce and “latest wins” helpers in TCA-style libraries do.

Cancellation is cooperative, so it is not enough on its own. In the third run the service wraps a callback API and ignores cancellation; the cancelled task still finishes and “ab” is applied last. The robust fix is the second line of defence in the reducer: tag every response with what it answers (the query, or a request counter) and drop it if it no longer matches the current state, which is the fourth run. Two more rules: a cancelled effect should not report an error (swallowing CancellationError deliberately, not with a blanket try? that also hides real failures), and a long-lived effect (a stream of updates) needs a way to be cancelled when the feature goes away, such as tying it to the view’s task or to the store’s lifetime. Delays use generous margins in the check (120 ms versus 20 ms, 400 ms to settle) because it is timing based; the ordering is what is asserted.

Cancellation only works if the service cooperates; checking the response against the current query makes stale results harmless either way.

Common traps

  • Relying on cancellation alone. A service that ignores cancellation still delivers; check the response against current state too.
  • Using try? around the whole effect. It swallows real errors along with cancellation; handle CancellationError explicitly.

They might follow up with

  • Should the reducer cancel, or the view? The reducer: effect lifetime is part of the feature’s logic, so tests cover it.
  • How is debounce different? Cancel-and-restart with a delay before the work starts, so rapid input starts nothing until it pauses (Q445 shows how to test it with a controlled clock).

Related questions

  • 15.7.3 How do you compose reducers: child features inside a parent, scoping state with key paths and embedding…
  • 15.1.2 What is unidirectional data flow, and how does a reducer make state changes predictable and testable?
  • 10.8.5 Which tasks inherit a @TaskLocal value, and when is it a better tool than a parameter?
  • 22.3.2 How do you test code that depends on time: debounce, timeouts, retries and expiry?

How do you model screen state so illegal combinations cannot exist… →