Counter¶
Source: sample/shared/…/examples/counter/
The counter is the simplest example — a single-state machine with synchronous transitions and a
mix of inline effects and fire-and-dispatch async patterns. It demonstrates that a machine does
not need a sealed state hierarchy: a single data class as the state type is perfectly valid.
Types¶
data class CounterState(val count: Int, val step: Int = 1) : State
sealed interface CounterAction : Action {
data object Increment : CounterAction
data object Decrement : CounterAction
data class SetStep(val step: Int) : CounterAction
data object Reset : CounterAction
// Dispatched back by the consumer after completing an async save:
data class SaveCompleted(val success: Boolean) : CounterAction
}
sealed interface CounterEffect : Effect {
data class ShowMessage(val text: String) : CounterEffect
// Instructs the consumer to persist the count; result comes back as SaveCompleted:
data class SaveCount(val count: Int) : CounterEffect
}
State machine¶
class CounterStateMachine(scope: CoroutineScope) :
Store<CounterState, CounterAction, CounterEffect> by store(scope = scope, builder = {
initialState(CounterState(count = 0, step = 1))
state<CounterState> {
on<CounterAction.Increment> {
transition(state.copy(count = state.count + state.step))
}
on<CounterAction.Decrement> {
transition(state.copy(count = state.count - state.step))
}
on<CounterAction.SetStep> {
if (action.step < 1) {
sideEffect(CounterEffect.ShowMessage("Step must be at least 1."))
} else {
transition(state.copy(step = action.step))
}
}
on<CounterAction.Reset> {
transition(state.copy(count = 0))
sideEffect(CounterEffect.ShowMessage("Counter reset!"))
sideEffect(CounterEffect.SaveCount(0))
}
on<CounterAction.SaveCompleted> {
if (action.success) sideEffect(CounterEffect.ShowMessage("Saved ✓"))
}
}
install(LoggingPlugin(tag = "Counter"))
})
Patterns demonstrated¶
Single-state machine¶
The entire state fits in one data class. There is only one state<CounterState> block — no
hierarchy, no parent catch-all. All four action handlers live inside it.
Conditional side effect without a transition¶
SetStep validates the input and either emits an effect (invalid) or transitions (valid) — no
else needed because sideEffect and transition are independent verb calls:
on<CounterAction.SetStep> {
if (action.step < 1) {
sideEffect(CounterEffect.ShowMessage("Step must be at least 1."))
} else {
transition(state.copy(step = action.step))
}
}
Multiple effects from one handler¶
Reset records a transition and two effects. Effects are emitted in call order after the
state change:
on<CounterAction.Reset> {
transition(state.copy(count = 0))
sideEffect(CounterEffect.ShowMessage("Counter reset!"))
sideEffect(CounterEffect.SaveCount(0)) // tells the UI/ViewModel to persist
}
Round-trip async save via actions¶
SaveCount is an effect that the consumer (ViewModel or Composable) handles by initiating an
async save. When the save completes, the consumer dispatches SaveCompleted back to the store,
which reacts with another effect:
// In the ViewModel or Composable:
store.handleEffects { effect ->
when (effect) {
is CounterEffect.SaveCount -> {
val ok = repository.save(effect.count)
store.dispatch(CounterAction.SaveCompleted(success = ok))
}
is CounterEffect.ShowMessage -> showSnackbar(effect.text)
}
}
This keeps async persistence logic outside the machine, which remains a pure function of actions → state/effects.