Skip to content

Concurrency and Collections

Abhi Muktheeswarar edited this page Aug 6, 2026 · 3 revisions

Flywheel's state machine processes one Action at a time on a single coroutine, so the State itself can never be corrupted. But there is a subtle hazard that lives above the state machine: the lost update.

The lost-update problem

A common pattern looks harmless:

  1. A SideEffect reads the State (via state(), awaitState(), or an actionStates snapshot).
  2. It computes a new collection from what it read — filtering, merging an API response, updating entries.
  3. It dispatches an Action carrying the whole new collection, and the reducer replaces the one in the State with it.

With a single writer this is fine. With concurrent SideEffects it silently loses data:

items = [1, 2, 3, 4, 5]
SideEffect A reads the snapshot, removes 2 and 4, dispatches UpdateItems([1, 3, 5])
SideEffect B reads the same snapshot, computes its change, dispatches UpdateItems([1, 2, 0, 4])
Reducer applies A: items = [1, 3, 5]
Reducer applies B: items = [1, 2, 0, 4]   ← A's removals are gone forever

Both actions carried values derived from the same old snapshot. The reducer applied them one at a time — correctly — but the second value had no knowledge of the first one's changes. Last writer wins; the other writer's work vanishes without an error.

awaitState() does not prevent this. It guarantees a fresh read, but both SideEffects can await, receive the same fresh state, and still overwrite each other on the way back.

The fix: dispatch the operation, not the result

Instead of sending "the items should become [1, 3, 5]" (a value computed from old information), send "remove 2 and 4 from whatever the items are now" (an operation that is correct no matter what happened in between). Flywheel gives you two ways to do this — both are regular, typed actions, so the flow stays action-driven.

1. ReduceAction — the primary fix

A ReduceAction is a normal Action that carries only the delta in its properties and its own reduce(state) function describing how to merge that delta. It is dispatched like any other action; when the state machine dequeues it, it calls reduce with the current State — not the snapshot you saw. Your reduce is effectively a one-time, named reducer, so the rule "state can only be updated by a reducer" still holds.

// SideEffect A — remove 2 and 4
data class RemoveItemsAction(val ids: Set<Int>) : ReduceAction<ItemsState> {
    override fun reduce(state: ItemsState) = state.copy(items = state.items - ids)
}

// SideEffect B — its own change, computed independently
data class ReplaceItemAction(val old: Int, val new: Int) : ReduceAction<ItemsState> {
    override fun reduce(state: ItemsState) =
        state.copy(items = state.items.map { if (it == old) new else it })
}

dispatch(RemoveItemsAction(setOf(2, 4)))
dispatch(ReplaceItemAction(old = 3, new = 0))

Whichever order they arrive, the second reduce runs against the state that already includes the first one's change. Nothing is lost.

Because a ReduceAction rides the regular action queue as a typed, named action, everything behaves as usual: FIFO ordering with other dispatches, Middleware sees it, it appears in the actions and actionStates flows with its payload visible in logs, and other SideEffects can react to it — the operation remains part of your app's action vocabulary.

Rules for reduce():

  • Keep it fast and pure — no I/O, no heavy iteration, no side effects. It runs on the state machine, so a slow reduce delays every queued action.
  • Do the heavy computation in the SideEffect before dispatching, and put only the precomputed delta in the action:
data class MergeItemsAction(val updatedById: Map<Id, Item>) : ReduceAction<ItemsState> {
    override fun reduce(state: ItemsState) =
        state.copy(items = state.items.map { updatedById[it.id] ?: it })
}

// Heavy work happens here, in the SideEffect, off the state machine
val updatedById = heavyProcessing(response)
// Dispatch the operation; only the cheap merge runs on the state machine
dispatch(MergeItemsAction(updatedById))
  • Write reduce defensively against concurrent edits: prefer key-based updates over index-based ones, and update-if-present rather than assuming an entry still exists.
  • A throwing reduce leaves the State unchanged — the same contract as a throwing reducer.

2. Delta actions merged in the central reducer

Equivalent safety with the merge living in your root reducer instead of the action — useful when one reducer handles a family of deltas:

sealed interface CollectionAction : Action {
    data class PutItems(val entries: Map<String, Int>) : CollectionAction   // delta
    data class RemoveItems(val keys: Set<String>) : CollectionAction        // delta
}

val reduce = reducerForAction<CollectionAction, CollectionState> { action, state ->
    when (action) {
        is PutItems -> state.copy(items = state.items + action.entries)
        is RemoveItems -> state.copy(items = state.items - action.keys)
        else -> state
    }
}

This is equally safe. Choose ReduceAction when the operation and its merge belong together (self-contained, works for changes plain merges can't express well, e.g. positional list edits computed against the current list); choose reducer-merged deltas when one reducer should own the merge logic for a family of actions.

What about "no iteration in reducers"?

The real rule is: no heavy work on the state machine — no I/O, no processing of large payloads, nothing that makes queued actions wait. Applying a precomputed delta (items + entries, items - keys, a single-pass merge) is a cheap operation and is perfectly fine in a reducer or a ReduceAction.reduce(). Compute what to change in the SideEffect; apply the change on the state machine.

Semantic conflicts still exist

ReduceAction and delta actions guarantee no update is silently lost. They do not decide what two overlapping changes mean — if one SideEffect removes an item another one is updating, the second reduce simply finds it absent and should handle that gracefully. That decision is inherent to concurrent edits and is the same one you would face writing both updates inside the reducer.

Clone this wiki locally