Koma

[!IMPORTANT]
Artifacts are published under the io.github.koma-kt group.
Koma is a state management framework for Kotlin Multiplatform.
Key benefits:
- The data flow is one-way, making it easy to reason about.
- Because state is immutable during processing, you don’t have to worry about side effects.
- Code becomes more declarative.
- Writing tests is straightforward.
- The state-machine-oriented DSL keeps state transitions explicit and readable.
- Works across multiple platforms.
- Enables code sharing and consistent logic across platforms.
The architecture is inspired by Flux and is as follows:
Quick Look
Describe each screen state with the Store{} DSL. Each state{} block groups the enter{}, action{}, and recover{} behavior that is valid for that state.
Sealed state variants make invalid UI states unrepresentable, such as verifying a code before it is complete or editing the code while verification is in progress.
When Koma Fits Best
Koma works especially well when a feature has multiple explicit UI or business states and the transition rules between them are important.
By combining Kotlin sealed class/sealed interface with Koma's state machine DSL, you can keep each state's enter{}, action{}, exit{}, and recover{} behavior close together and make the transition rules easy to follow.
Current Scope
Koma currently focuses on the core pieces of state management: explicit state transitions, coroutine-based asynchronous work, state persistence, and plugin-driven extensions such as logging and inter-store messaging.
It keeps surrounding helper layers intentionally small, so dependencies and feature composition can stay in ordinary Kotlin, while the core Store logic remains portable across platforms.
Table of Contents
Installation
implementation("io.github.koma-kt:koma-core:<latest-release>")
Usage
Basic
Let’s take a simple counter app as an example.
First, define the State and Action classes.
data class CounterState(val count: Int) : State
sealed interface CounterAction : Action {
data object Increment : CounterAction
data object Decrement : CounterAction
}
Create a Store using the Store{} DSL and an initial State.
val store: Store<CounterState, CounterAction, Nothing> = Store(CounterState(count = 0)) {}
val store: Store<CounterState, CounterAction, Nothing> = Store {
initialState(CounterState(count = 0))
}
Define how Actions change State using the state{} and action{} blocks.
Specify the resulting State with nextState { ... }.
val store: Store<CounterState, CounterAction, Nothing> = Store(CounterState(count = 0)) {
state<CounterState> {
action<CounterAction.Increment> {
nextState { state.copy(count = state.count + 1) }
}
action<CounterAction.Decrement> {
if (0 < state.count) {
nextState { state.copy(count = state.count - 1) }
} else {
}
}
}
}
If nextState { ... } is not specified, the current state remains unchanged.
If nextState { ... } is called multiple times in the same handler, the last computed value is applied.
For conditional or complex updates, nextState {} can make the computation easier to read. The value of the block's final expression is used as the next state.
nextState {
val newCount = ...
state.copy(count = newCount)
}
The Store setup is complete.
Keep the store instance in a ViewModel (or similar).
Dispatch an Action from the UI using the Store's dispatch() method.
Button(
onClick = { store.dispatch(CounterAction.Increment) },
) {
Text(text = "increment")
}
The new State is exposed via the Store's .state (StateFlow), so render it in the UI.
Delivering events to the UI
Define your Event class and set it as the third type parameter of Store.
sealed interface CounterEvent : Event {
data class ShowToast(val message: String) : CounterEvent
data object NavigateToNextScreen : CounterEvent
}
val store: Store<CounterState, CounterAction, CounterEvent> = Store(CounterState(count = 0)) {
}
In an action{} block, specify an Event with event().
action<CounterAction.Decrement> {
if (0 < state.count) {
nextState { state.copy(count = state.count - 1) }
} else {
event(CounterEvent.ShowToast("Can not Decrement."))
}
}
Collect the Store's .event (Flow) in the UI and handle it.
Access repositories and UseCase classes
Have repositories and UseCase classes available in your store creation scope and use them inside action{} blocks.
fun CounterStore: Store<CounterState, CounterAction, CounterEvent> = Store(CounterState(count = )) {
state<CounterState> {
action<CounterAction.Load> {
count = counterRepository.()
nextState { state.copy(count = count) }
}
action<CounterAction.Increment> {
count = state.count +
counterRepository.(count)
nextState { state.copy(count = count) }
}
}
}
(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> Store(
initialState = CounterState(count = ),
builder = {
state<CounterState> {
}
},
)
Multiple states and transitions
In the previous examples, the State was single.
If you need multiple States (for example, a UI during data loading), define them explicitly.
sealed interface CounterState : State {
data object Loading : CounterState
data class Main(val count: Int) : CounterState
}
fun CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store(CounterState.Loading) {
state<CounterState.Loading> {
action<CounterAction.Load> {
val count = counterRepository.get()
nextState { CounterState.Main(count = count) }
}
}
state<CounterState.Main> {
action<CounterAction.Increment> {
In this example, the CounterAction.Load action needs to be issued from the UI when the application starts.
If you want to run logic when a State starts, use the enter{} block (similarly, you can use the exit{} block if necessary).
fun CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store(CounterState.Loading) {
state<CounterState.Loading> {
enter {
val count = counterRepository.get()
nextState { CounterState.Main(count = count) }
}
}
state<CounterState.Main> {
action<CounterAction.Increment> {
The state diagram is as follows:
This framework's architecture can be easily visualized using state diagrams.
It would be a good idea to document it and share it with your development team.
Parent state/action handlers
You can also target a parent sealed type in state<...>{} or action<...>{} when the same handler should apply across multiple variants.
val store: Store<CounterState, CounterAction, CounterEvent> = Store(CounterState.Loading) {
state<CounterState> {
enter {
}
}
state<CounterState.Main> {
action<CounterAction> {
when (action) {
CounterAction.Increment -> {
}
CounterAction.Decrement -> {
}
CounterAction.Load -> Unit
}
}
}
}
Handler selection is first-match.
If both broad and specific handlers can match, the one registered earlier is used.
In practice, place broader handlers after more specific ones.
state<CounterState.Loading> {
action<CounterAction.Load> {
}
}
state<CounterState> {
action<CounterAction> {
}
}
Error handling
If you prepare a State for error display and handle the error in the enter{} block, it will be as follows:
sealed interface CounterState : State {
data class Error(val error: Exception) : CounterState
}
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
state<CounterState.Loading> {
enter {
try {
val count = counterRepository.get()
nextState { CounterState.Main(count = count) }
} catch (e: Exception) {
nextState { CounterState.Error(error = e) }
}
}
}
}
This works, but you can also handle exceptions with the recover{} block.
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
state<CounterState.Loading> {
enter {
val count = counterRepository.get()
nextState { CounterState.Main(count = count) }
}
recover<IllegalStateException> {
nextState { CounterState.Error(error = error) }
}
recover<Exception> {
nextState { CounterState.Error(error = error) }
}
}
}
Exceptions can be caught not only in the enter{} block but also in the action{} and exit{} blocks.
In other words, your business logic exceptions can be handled in the recover{} block.
On the other hand, fatal errors and other uncaught non-Exception throwables in the entire Store can be handled with the exceptionHandler() specification:
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
exceptionHandler(...)
}
You can also create an ExceptionHandler instance with the ExceptionHandler() factory function.
Asynchronous Work
You can use launch{} in both enter{} and action{} blocks to run asynchronous work and update State (or emit Events).
This is useful for integrating long-running tasks such as flow collection, network calls, and background processing:
state<MyState.Active> {
enter {
launch {
dataRepository.observeData().collect { newData ->
transaction {
nextState { state.copy(data = newData) }
}
}
}
}
}
You can also start asynchronous work from an action:
state<MyState.Active> {
action<MyAction.Refresh> {
launch {
transaction {
nextState { state.copy(isRefreshing = true) }
}
dataRepository.refresh()
transaction {
nextState { state.copy(isRefreshing = false) }
}
}
}
}
This pattern lets your Store react to external data changes automatically, such as database updates, user preference changes, or network events.
Coroutines started by launch{} are automatically cancelled when the State changes to a different State, making it easy to manage resources and subscriptions.
In action{}, launch{} is tied to the State active at action start.
If you want lightweight coordination and explicit cancellation for coroutines launched from an action handler, set the control directly on launch(...):
val store = Store(MyState.Active()) {
val searchLane = LaunchLane()
state<MyState.Active> {
action<MyAction.QueryChanged> {
nextState { state.copy(query = action.query, isLoading = true) }
launch(control = LaunchControl.CancelPrevious(searchLane)) {
delay(300)
val result = repository.search(action.query)
transaction {
nextState {
state.copy(
result = result,
isLoading = false,
)
}
}
}
}
action<MyAction.ClearQuery> {
cancelLaunch(searchLane)
nextState {
state.copy(
query = ,
result = emptyList(),
isLoading = ,
)
}
}
action<MyAction.Submit> {
launch(control = LaunchControl.DropIfRunning()) {
submit()
}
}
}
}
cancels the previous tracked launch in the same lane before starting the next one.
ignores a new launch while tracked work in the same lane is still active.
When the lane is omitted, and use the same internal default lane for that block.
keeps the default behavior and runs launches independently.
only affects coroutines started from in the current active state's runtime that use tracked controls such as and . Use an explicit when you need to share a lane across multiple launches or cancel it later. It does not cancel launches or .
Specifying coroutineContext
The Store operates using Coroutines, and its default base CoroutineContext is Dispatchers.Default.
Specify it to align the Store's Coroutines lifecycle with another context or to change the execution thread.
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
coroutineContext(...)
}
If you don’t use an auto-managed scope like ViewModel's viewModelScope or Compose's rememberCoroutineScope(), call Store's .close() method explicitly when the Store is no longer needed.
Then, processing of all Coroutines will stop.
Specifying CoroutineDispatchers
You can specify the execution thread (CoroutineDispatchers) in enter{}, exit{}, action{}, recover{}, and launch{} blocks, allowing you to locally control which thread each specific operation runs on.
If you omit the dispatcher parameter, Koma keeps using the Store's current execution context for that operation.
enter(Dispatchers.Default) {
launch(Dispatchers.IO) {
val updates = dataRepository.observeUpdates()
updates.collect { newData ->
}
}
}
Alternatively, you can use Coroutines' withContext().
enter {
withContext(Dispatchers.Default) {
withContext(Dispatchers.IO) {
launch {
val updates = dataRepository.observeUpdates()
updates.collect { newData ->
}
}
}
}
}
State Persistence
You can prepare a StateSaver to automatically handle State persistence:
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
stateSaver(...)
}
You can also create a StateSaver instance with the StateSaver() factory function.
Clear Pending Actions
By default, Koma clears already queued actions when the store exits the current state and enters a different state variant.
To keep queued actions across state exits, set pendingActionPolicy(PendingActionPolicy.Keep).
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
pendingActionPolicy(PendingActionPolicy.Keep)
}
Regardless of the configured PendingActionPolicy, you can still discard already queued actions at a specific point by calling clearPendingActions() inside enter{}, action{}, exit{}, recover{}, or inside transaction{} from a launched coroutine.
Using Control Flow in Store{}
The body of Store{} is ordinary Kotlin code, so you can use control flow such as if and when when specifying Store configuration.
fun CounterStore(
logExceptions: Boolean,
): Store<CounterState, CounterAction, Nothing> = Store {
initialState(CounterState(count = 0))
if (logExceptions) {
exceptionHandler(ExceptionHandler.Log)
}
}
For Platforms Without Flow/StateFlow Access
On platforms where Store's .state (StateFlow) and .event (Flow) cannot be consumed directly (e.g., iOS), use .collectState() and .collectEvent().
If the State or Event changes, you will be notified through these callbacks.
These callbacks run in the Store's execution context. Koma does not automatically switch to a UI thread, so move to the appropriate UI thread before touching UI components when needed.
Store startup is lazy. By default, the Store starts on the first .dispatch(...) or when state collection begins through .state or .collectState().
If you want state collection not to start the Store automatically, set autoStartPolicy(AutoStartPolicy.OnDispatch) and call .start() when you want to trigger startup explicitly.
Compose
Plugin
Project-specific AppStore Wrapper
In larger projects, it can be useful to wrap Store{} DSL in a project-specific AppStore{} that applies app-wide defaults in one place.
This lets you centralize shared Store configuration.
fun <S : State, A : Action, E : Event> AppStore(
initialState: S,
builder: StoreBuilder<S, A, E>.() -> Unit,
): Store<S, A, E> = Store(
initialState = initialState,
) {
plugin(AppLoggingPlugin())
exceptionHandler(AppExceptionHandler)
builder()
}
A feature Store can then focus on its own state transitions and actions:
fun CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = AppStore(
initialState = CounterState(count = 0),
) {
state<CounterState> {
}
}
Testing Store
Add :koma-test to your test source set to use Koma's test helpers.
commonTestImplementation("io.github.koma-kt:koma-test:<latest-release>")
Use dispatchAndAwait(action) to dispatch an Action and suspend until the Store finishes processing it. It waits for startup (when needed), the matching action {} handler, and the resulting synchronous state transition work, but not for additional work launched with launch {}.
@Test
fun counterStore_dispatchesAndProcesses() = runTest {
val store = CounterStore(...)
store.dispatchAndAwait(CounterAction.Increment)
assertEquals(CounterState(count = 1), store.currentState)
}
If you want to inspect only the startup phase (plugin onStart hooks and the synchronous enter {} chain) without dispatching an Action, use startAndAwait().
For most Store tests, use createRecorder() to create and attach the default StoreRecorder, then assert recorded state and event history.
@Test
fun counterStore_recordsStatesAndEvents() = runTest {
val store = CounterStore(...)
val recorder = store.createRecorder()
store.dispatchAndAwait(CounterAction.Increment)
assertEquals(
listOf(
CounterState(count = 0),
CounterState(count = 1),
),
recorder.states,
)
assertEquals(
listOf(CounterEvent.Incremented(count = 1)),
recorder.events,
)
}
Or use record { recorder -> ... } to scope a recording session to a block. The Store is the receiver, so dispatchAndAwait() can be called without a prefix.
store.record { recorder ->
dispatchAndAwait(CounterAction.Increment)
assertEquals(listOf(CounterState(0), CounterState(1)), recorder.states)
}
If you need custom recording behavior, implement your own Plugin and register it via patch { plugin(...) }.
If your action {} or enter {} logic launches additional coroutines with launch {}, or if you need virtual time control, use test dispatcher and scheduler control separately.
Use patch { ... } when you want to replace non-state Store configuration in tests without rewriting the Store definition.
fun CounterStore(): Store<CounterState, CounterAction, Nothing> = Store(
initialState = CounterState(count = 0),
) {
plugin(AppLoggingPlugin())
state<CounterState> {
}
}
val store = CounterStore().patch {
clearPlugins()
exceptionHandler(ExceptionHandler.Log)
}
Inside patch block, you can use these APIs:
initialState(...)
coroutineContext(...)