
Lightweight, type-safe finite state machine DSL enabling lifecycle-tied asynchronous side effects, observable state stream and one-time effects, with restart-control keys, nested graphs, and tiny zero-dependency core.
A lightweight, type-safe, and highly performant Finite State Machine (FSM) library built for Kotlin Multiplatform. Designed specifically to handle complex logic transitions with a clean DSL, while maintaining a tiny footprint.
SharingStarted.WhileSubscribed(5.seconds).Add the dependency to your commonMain source set:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.klitsie.statemachine:statemachine-core:0.3.0")
}
}
}sealed interface QuizState {
data object Idle : QuizState
sealed interface InProgress : QuizState {
data class Playing(val questionId: String, val score: Int) : InProgress
data object Loading : QuizState
}
data class Finished(val finalScore: Int) : QuizState
}
sealed interface QuizEffect {
data object PlayCorrectSound : QuizEffect
data object PlayWrongSound : QuizEffect
}
sealed interface QuizEvent {
data object Start : QuizEvent
data object Cancel : QuizEvent
data class Answer(val isCorrect: Boolean) : QuizEvent
data object TimeOut : QuizEvent
data class NextQuestion(val id: String) : QuizEvent
}val machine = stateMachine<QuizState, QuizEffect, QuizEvent>(
scope = viewModelScope, // All work is tied to this scope's lifecycle
initialState = QuizState.Idle,
) {
// Runs again on every state change in the machine
sideEffect { state ->
try {
println("Entered state: $state")
awaitCancellation()
} finally {
println("Exited state: $state")
}
}
// Top-level state definition
state<QuizState.Idle> {
onEvent<QuizEvent.Start> { _, _ ->
QuizState.InProgress.Loading
}
}
// Define a sub-graph for states sharing common logic
nestedState<QuizState.InProgress> {
// Shared handler: allows 'Cancel' from anywhere within InProgress (Loading or Playing)
onEvent<QuizEvent.Cancel> { _, _ -> QuizState.Idle }
state<QuizState.InProgress.Loading> {
// sideEffect starts when Loading is entered and cancels when exited
sideEffect {
val data = repository.fetchQuestions()
// You can send events from within sideEffects
send(QuizEvent.NextQuestion(data.first().id))
}
onEvent<QuizEvent.NextQuestion> { _, event ->
QuizState.InProgress.Playing(questionId = event.id, score = 0)
}
}
state<QuizState.InProgress.Playing> {
// key: Only restarts if questionId changes.
// If the state changes (e.g., score updates) but ID is the same, the timer keeps running.
sideEffect(key = { it.questionId }) {
delay(30.seconds)
send(QuizEvent.TimeOut)
}
onEvent<QuizEvent.Answer> { state, event ->
if (event.isCorrect) {
// Triggers a one-time event on the 'effects' flow
trigger(QuizEffect.PlayCorrectSound)
// Transitions to a new state instance with an updated score
state.copy(score = state.score + 10)
} else {
// trigger(..) returns the current state to skip transition
trigger(QuizEffect.PlayWrongSound)
}
}
onEvent<QuizEvent.TimeOut> { state, _ ->
QuizState.Finished(finalScore = state.score)
}
}
}
}// The machine IS a StateFlow
lifecycleScope.launch {
machine.collect { currentState ->
updateUI(currentState)
}
}
// Observe one-time effects
lifecycleScope.launch {
machine.consumeEffects { effect ->
when (effect) {
QuizEffect.PlayCorrectSound -> audioPlayer.play("success.mp3")
QuizEffect.PlayWrongSound -> audioPlayer.play("error.mp3")
}
}
}@Composable
@Composable
fun QuizScreen(
viewModel: QuizViewModel,
audioPlayer: AudioPlayer,
) {
// Lifecycle-aware collection using the machine's StateFlow implementation
val state by viewModel.machine.collectAsStateWithLifecycle()
LaunchedEffect(viewModel.machine) {
viewModel.machine.consumeEffects { effect ->
when (effect) {
QuizEffect.PlayCorrectSound -> audioPlayer.play("success.mp3")
QuizEffect.PlayWrongSound -> audioPlayer.play("error.mp3")
}
}
}
when (val s = state) {
is QuizState.Idle -> StartButton(onClick = { viewModel.machine.send(QuizEvent.Start) })
is QuizState.InProgress.Loading -> LoadingSpinner()
is QuizState.InProgress.Playing -> ScoreBoard(s.score)
is QuizState.Finished -> ResultsScreen(s.finalScore)
}
}This library is designed for the modern web. It avoids reflection and heavy JVM APIs, keeping your bundle small and your logic fast across both Wasm-JS and JS targets.
This project is licensed under the Apache License, Version 2.0.
Copyright 2026 klitsie.dev
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
[http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)
Maintained by klitsie.dev
A lightweight, type-safe, and highly performant Finite State Machine (FSM) library built for Kotlin Multiplatform. Designed specifically to handle complex logic transitions with a clean DSL, while maintaining a tiny footprint.
SharingStarted.WhileSubscribed(5.seconds).Add the dependency to your commonMain source set:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.klitsie.statemachine:statemachine-core:0.3.0")
}
}
}sealed interface QuizState {
data object Idle : QuizState
sealed interface InProgress : QuizState {
data class Playing(val questionId: String, val score: Int) : InProgress
data object Loading : QuizState
}
data class Finished(val finalScore: Int) : QuizState
}
sealed interface QuizEffect {
data object PlayCorrectSound : QuizEffect
data object PlayWrongSound : QuizEffect
}
sealed interface QuizEvent {
data object Start : QuizEvent
data object Cancel : QuizEvent
data class Answer(val isCorrect: Boolean) : QuizEvent
data object TimeOut : QuizEvent
data class NextQuestion(val id: String) : QuizEvent
}val machine = stateMachine<QuizState, QuizEffect, QuizEvent>(
scope = viewModelScope, // All work is tied to this scope's lifecycle
initialState = QuizState.Idle,
) {
// Runs again on every state change in the machine
sideEffect { state ->
try {
println("Entered state: $state")
awaitCancellation()
} finally {
println("Exited state: $state")
}
}
// Top-level state definition
state<QuizState.Idle> {
onEvent<QuizEvent.Start> { _, _ ->
QuizState.InProgress.Loading
}
}
// Define a sub-graph for states sharing common logic
nestedState<QuizState.InProgress> {
// Shared handler: allows 'Cancel' from anywhere within InProgress (Loading or Playing)
onEvent<QuizEvent.Cancel> { _, _ -> QuizState.Idle }
state<QuizState.InProgress.Loading> {
// sideEffect starts when Loading is entered and cancels when exited
sideEffect {
val data = repository.fetchQuestions()
// You can send events from within sideEffects
send(QuizEvent.NextQuestion(data.first().id))
}
onEvent<QuizEvent.NextQuestion> { _, event ->
QuizState.InProgress.Playing(questionId = event.id, score = 0)
}
}
state<QuizState.InProgress.Playing> {
// key: Only restarts if questionId changes.
// If the state changes (e.g., score updates) but ID is the same, the timer keeps running.
sideEffect(key = { it.questionId }) {
delay(30.seconds)
send(QuizEvent.TimeOut)
}
onEvent<QuizEvent.Answer> { state, event ->
if (event.isCorrect) {
// Triggers a one-time event on the 'effects' flow
trigger(QuizEffect.PlayCorrectSound)
// Transitions to a new state instance with an updated score
state.copy(score = state.score + 10)
} else {
// trigger(..) returns the current state to skip transition
trigger(QuizEffect.PlayWrongSound)
}
}
onEvent<QuizEvent.TimeOut> { state, _ ->
QuizState.Finished(finalScore = state.score)
}
}
}
}// The machine IS a StateFlow
lifecycleScope.launch {
machine.collect { currentState ->
updateUI(currentState)
}
}
// Observe one-time effects
lifecycleScope.launch {
machine.consumeEffects { effect ->
when (effect) {
QuizEffect.PlayCorrectSound -> audioPlayer.play("success.mp3")
QuizEffect.PlayWrongSound -> audioPlayer.play("error.mp3")
}
}
}@Composable
@Composable
fun QuizScreen(
viewModel: QuizViewModel,
audioPlayer: AudioPlayer,
) {
// Lifecycle-aware collection using the machine's StateFlow implementation
val state by viewModel.machine.collectAsStateWithLifecycle()
LaunchedEffect(viewModel.machine) {
viewModel.machine.consumeEffects { effect ->
when (effect) {
QuizEffect.PlayCorrectSound -> audioPlayer.play("success.mp3")
QuizEffect.PlayWrongSound -> audioPlayer.play("error.mp3")
}
}
}
when (val s = state) {
is QuizState.Idle -> StartButton(onClick = { viewModel.machine.send(QuizEvent.Start) })
is QuizState.InProgress.Loading -> LoadingSpinner()
is QuizState.InProgress.Playing -> ScoreBoard(s.score)
is QuizState.Finished -> ResultsScreen(s.finalScore)
}
}This library is designed for the modern web. It avoids reflection and heavy JVM APIs, keeping your bundle small and your logic fast across both Wasm-JS and JS targets.
This project is licensed under the Apache License, Version 2.0.
Copyright 2026 klitsie.dev
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
[http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)
Maintained by klitsie.dev