-
Notifications
You must be signed in to change notification settings - Fork 3
Concurrency and Collections
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.
A common pattern looks harmless:
- A
SideEffectreads theState(viastate(),awaitState(), or anactionStatessnapshot). - It computes a new collection from what it read — filtering, merging an API response, updating entries.
- It dispatches an
Actioncarrying the whole new collection, and the reducer replaces the one in theStatewith 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.
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.
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
reducedelays every queued action. - Do the heavy computation in the
SideEffectbefore 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
reducedefensively against concurrent edits: prefer key-based updates over index-based ones, and update-if-present rather than assuming an entry still exists. - A throwing
reduceleaves theStateunchanged — the same contract as a throwing 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.
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.
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.