GitHunt
AS

asifrpatel/StateMachine

A simple, yet powerful, State Machine to manage UI complex UI states, written in Kotlin for multiplatform usage!

Maven Central
Kotlin
Platform
License
Coverage

Kotlin Multiplatform State Machine

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.

๐Ÿš€ Key Features

  • Multiplatform First: Native support for Android, iOS, JVM, JS and Wasm-JS.
  • Presentation-Layer Focused: The machine is optimized to live within a CoroutineScope (like viewModelScope). Any
    work or side-effects are automatically cancelled when the scope is cleared.
  • Implements StateFlow: The machine itself implements StateFlow<S>, allowing it to be collected directly. By
    default, it uses SharingStarted.WhileSubscribed(5.seconds).
  • One-Time Effects: Exposes an Effect flow for one-time events (like navigation or toasts) that shouldn't be part of
    the persistent state.
  • Lifecycle-Aware Side Effects: Use the sideEffect { } block to launch work that starts on entry and cancels on
    exit, similar to Compose's LaunchedEffect.
  • Restart Control with Keys: Provide a key = { } to sideEffect to prevent restarts when the state data changes but
    the logical identity remains the same.
  • Type-Safe DSL: Leverage Kotlin's sealed classes for compile-time safety.
  • Wasm Optimized: Zero-dependency core, ideal for web applications.

๐Ÿ“ฆ Installation

Add the dependency to your commonMain source set:

kotlin {
	sourceSets {
		commonMain.dependencies {
			implementation("dev.klitsie.statemachine:statemachine-core:0.2.0")
		}
	}
}

๐Ÿ› ๏ธ Usage

1. Define your Graph

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
}

2. Configure the Machine

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 same, 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 updated score
					state.copy(score = state.score + 10)
				} else {
					trigger(QuizEffect.PlayWrongSound)
					state // Return current state to skip transition
				}
			}

			onEvent<QuizEvent.TimeOut> { state, _ ->
				QuizState.Finished(finalScore = state.score)
			}
		}
	}
}

3. Observe State and Effects

// The machine IS a StateFlow
lifecycleScope.launch {
	machine.collect { currentState ->
		updateUI(currentState)
	}
}

// Observe one-time effects
lifecycleScope.launch {
	machine.effects.collect { effect ->
		when (effect) {
			QuizEffect.PlayCorrectSound -> audioPlayer.play("success.mp3")
			QuizEffect.PlayWrongSound -> audioPlayer.play("error.mp3")
		}
	}
}

๐ŸŽจ Compose Integration

@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.effects) {
		viewModel.machine.effects.collect { 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)
	}
}

๐ŸŒ Wasm-JS & JS Specifics

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.

๐Ÿ“„ License

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

asifrpatel/StateMachine | GitHunt