A Kotlin Multiplatform MVI (Model-View-Intent) library. Minimal API surface, zero boilerplate, full coroutine integration.
Pulse separates UI state management into predictable, testable layers: a pure reducer for synchronous state transitions, an intent handler for async side-effects, and one-shot effects for transient events like navigation or toasts.
| Module | Description | Targets |
|---|---|---|
pulse |
Core MVI container. Pure Kotlin + coroutines. | JVM, Android, iOS, macOS, Linux, Windows, watchOS, tvOS |
pulse-viewmodel |
AndroidX ViewModel integration. | JVM, Android, iOS |
pulse-savedstate |
Auto-persist state via SavedStateHandle. | JVM, Android, iOS |
pulse-compose |
Compose Multiplatform extensions. | JVM, Android, iOS |
pulse-test |
Test utilities with synchronous dispatch. | JVM, iOS, macOS, Linux, Windows |
pulse-compose --> pulse
pulse-viewmodel --> pulse
pulse-savedstate --> pulse-viewmodel --> pulse
pulse-test --> pulse
pulse has a single dependency: kotlinx-coroutines-core.
Add the modules you need to your build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.bitsycore:pulse:<version>")
implementation("com.bitsycore:pulse-viewmodel:<version>")
implementation("com.bitsycore:pulse-compose:<version>")
implementation("com.bitsycore:pulse-savedstate:<version>")
}
commonTest.dependencies {
implementation("com.bitsycore:pulse-test:<version>")
}
}
}A contract declares the state, intents, and effects for a screen. It is the single source of truth.
object CounterContract : ContainerContract<CounterContract.UiState, CounterContract.Intent, CounterContract.Effect>() {
data class UiState(
val count: Int = 0,
)
sealed interface Intent {
data object Increment : Intent
data object Decrement : Intent
data object Reset : Intent
}
sealed interface Effect {
data class ShowToast(val message: String) : Effect
}
}The ViewModel holds the container, defines state reductions, and handles side-effects.
class CounterViewModel : PulseViewModel<UiState, Intent, Effect>(CounterContract) {
override val initialState: UiState
get() = UiState()
override fun reduce(state: UiState, intent: Intent): UiState = when (intent) {
Intent.Increment -> state.copy(count = state.count + 1)
Intent.Decrement -> state.copy(count = state.count - 1)
Intent.Reset -> state.copy(count = 0)
}
override suspend fun handleIntent(intent: Intent) {
when (intent) {
Intent.Reset -> emitEffect(Effect.ShowToast("Counter reset"))
else -> {}
}
}
}@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel { CounterViewModel() }) {
val state by viewModel.collectAsState()
viewModel.collectEffect { effect ->
when (effect) {
is Effect.ShowToast -> { /* show snackbar */ }
}
}
CounterContent(state, viewModel::dispatch)
}
@Composable
fun CounterContent(state: UiState, dispatch: (Intent) -> Unit) {
Column {
Text("Count: ${state.count}")
Button(onClick = { dispatch(Intent.Increment) }) { Text("+") }
Button(onClick = { dispatch(Intent.Decrement) }) { Text("-") }
Button(onClick = { dispatch(Intent.Reset) }) { Text("Reset") }
}
}Container is the MVI engine. It receives intents, runs them through a pure reducer for state transitions, then launches an async handler for side-effects.
flowchart LR
UI["UI"] -->|"dispatch(Intent)"| R["reduce()"]
R -->|"new State"| UI2["UI recomposes"]
UI -->|"dispatch(Intent)"| H["handleIntent()"]
H -->|"async work"| E["emitEffect()"]
reduce(state, intent)-- Pure, synchronous. Returns the next state. No side-effects.handleIntent(intent)-- Suspend function for async work (network, database, etc.).emitEffect(effect)-- Emits a one-shot event. Replay-safe: late collectors receive the last unconsumed effect without double-delivery.updateState { copy(...) }-- Convenience for modifying state outside the reducer (e.g., inside callbacks).dispatchDebounced(intent, delay)-- Rate-limits rapid input (search fields, sliders). Configurable debounce key, skip-if-unchanged, and cross-type sharing.
Groups the three MVI types into a single object. Does not hold state — initialState is provided by the ViewModel.
object MyContract : ContainerContract<MyState, MyIntent, MyEffect>()Interface exposing the public API of a container:
interface ContainerHost<STATE, INTENT, EFFECT> {
val stateFlow: StateFlow<STATE>
val effectFlow: Flow<EFFECT>
fun dispatch(intent: INTENT)
fun dispatchDebounced(intent: INTENT, delay: Duration, ...)
}Effects are wrapped internally in a Consumable -- a thread-safe one-shot wrapper. The backing SharedFlow replays the last N effects to late collectors (e.g., after configuration change), but each effect is delivered exactly once. The public API remains Flow<EFFECT>; consumers never handle Consumable directly.
Wraps Container in an AndroidX ViewModel. The container's coroutine scope is tied to viewModelScope.
class MyViewModel : PulseViewModel<MyState, MyIntent, MyEffect>(MyContract) {
override val initialState: MyState
get() = MyState()
override fun reduce(state: MyState, intent: MyIntent): MyState = ...
override suspend fun handleIntent(intent: MyIntent) { ... }
}Extends PulseViewModel with automatic state persistence via SavedStateHandle. State is serialized to JSON on every change and restored on creation. Requires STATE to be @Serializable.
@Serializable
data class UiState(val count: Int = 0)
class MyViewModel(savedStateHandle: SavedStateHandle) :
PulseSavedStateViewModel<UiState, Intent, Effect>(
containerContract = MyContract,
savedStateHandle = savedStateHandle,
serializer = UiState.serializer()
) {
override val initialState: UiState
get() = UiState()
override fun reduce(state: UiState, intent: Intent): UiState = ...
}In Compose:
val viewModel: MyViewModel = viewModel { MyViewModel(createSavedStateHandle()) }State survives process death and backstack eviction as long as the SavedStateHandle is alive.
Lifecycle-aware state collection. Defaults to Lifecycle.State.STARTED.
val state by viewModel.collectAsState()Lifecycle-aware one-shot effect collector.
viewModel.collectEffect { effect ->
when (effect) {
is Effect.Navigate -> navigator.navigate(effect.route)
is Effect.ShowToast -> snackbarHostState.showSnackbar(effect.message)
}
}Maps Android lifecycle events to intents. Dispatch lifecycle-driven logic without leaking lifecycle awareness into the ViewModel.
viewModel.onLifecycleIntent {
onCreate { Intent.OnCreated }
onStart { Intent.OnStarted }
onResume { Intent.OnResumed }
onPause { Intent.OnPaused }
onStop { Intent.OnStopped }
onDestroy { Intent.OnDestroyed }
}Maps Compose composition enter/exit events to intents.
viewModel.onCompositionIntent {
onEnter { Intent.OnScreenEntered }
onExit { Intent.OnScreenExited }
}A lightweight sub-container for complex nested state. Has its own reducer but no effects or async handling. Useful for reusable UI components (color pickers, form fields, etc.) that can be embedded in a parent screen's state.
object ColorPickerComponent : ComponentContract<ColorPickerComponent.State, ColorPickerComponent.Intent>() {
override val initialState = State()
@Serializable
data class State(
val red: Float = 0f,
val green: Float = 0f,
val blue: Float = 0f,
)
sealed interface Intent {
data class SetRed(val value: Float) : Intent
data class SetGreen(val value: Float) : Intent
data class SetBlue(val value: Float) : Intent
}
override fun reduce(state: State, intent: Intent): State = when (intent) {
is Intent.SetRed -> state.copy(red = intent.value.coerceIn(0f, 1f))
is Intent.SetGreen -> state.copy(green = intent.value.coerceIn(0f, 1f))
is Intent.SetBlue -> state.copy(blue = intent.value.coerceIn(0f, 1f))
}
}Embed in a parent contract:
object PageContract : ContainerContract<PageContract.UiState, PageContract.Intent, PageContract.Effect>() {
data class UiState(
val title: String = "",
val colorPicker: ColorPickerComponent.State = ColorPickerComponent.initialState,
)
sealed interface Intent {
data class ColorPicker(val intent: ColorPickerComponent.Intent) : Intent
}
}Delegate in the ViewModel reducer:
override fun reduce(state: UiState, intent: Intent): UiState = when (intent) {
is Intent.ColorPicker -> state.copy(
colorPicker = ColorPickerComponent.reduce(state.colorPicker, intent.intent)
)
}Rate-limit rapid user input. Only the last intent within the delay window is dispatched.
// Basic: debounce by intent type (default key)
viewModel.dispatchDebounced(Intent.Search(query), delay = 300.milliseconds)
// Custom key: independent debounce per field
viewModel.dispatchDebounced(Intent.UpdateName(name), delay = 300.milliseconds, key = "name")
viewModel.dispatchDebounced(Intent.UpdateEmail(email), delay = 300.milliseconds, key = "email")
// Skip unchanged: drop duplicate intents
viewModel.dispatchDebounced(Intent.Search(query), delay = 300.milliseconds, skipIfUnchanged = true)
// Share across types: different intent types cancel each other
viewModel.dispatchDebounced(Intent.Search(query), delay = 300.milliseconds, shareAcrossTypes = true)pulse-test provides a synchronous test container and assertion utilities.
@Test
fun incrementUpdatesCount() = CounterContract.containerTest(
initialState = CounterContract.UiState(),
reduce = { state, intent ->
when (intent) {
Intent.Increment -> state.copy(count = state.count + 1)
Intent.Decrement -> state.copy(count = state.count - 1)
Intent.Reset -> state.copy(count = 0)
}
}
) {
dispatch(Intent.Increment)
assertState { it.count == 1 }
dispatch(Intent.Increment)
dispatch(Intent.Increment)
assertState(UiState(count = 3))
}@Test
fun resetEmitsToast() = CounterContract.containerTest(
initialState = CounterContract.UiState(),
handleIntent = { intent ->
when (intent) {
Intent.Reset -> emitEffect(Effect.ShowToast("Counter reset"))
else -> {}
}
}
) {
val effect = awaitEffect {
dispatch(Intent.Reset)
}
assertEquals(Effect.ShowToast("Counter reset"), effect)
}@Test
fun multipleEffects() = runTest {
val container = TestContainer(
contract = CounterContract,
initialState = CounterContract.UiState(),
testScope = this,
intentHandler = { intent ->
when (intent) {
Intent.Increment -> emitEffect(Effect.ShowToast("inc"))
Intent.Reset -> emitEffect(Effect.ShowToast("reset"))
else -> {}
}
}
)
val effects = container.collectEffects(this) {
container.dispatch(Intent.Increment)
container.dispatch(Intent.Reset)
}
assertEquals(2, effects.size)
}ComponentContract has a pure reducer with no async behavior. Test it directly.
@Test
fun colorPickerClampsValues() {
val state = ColorPickerComponent.reduce(
ColorPickerComponent.initialState,
ColorPickerComponent.Intent.SetRed(1.5f)
)
assertEquals(1f, state.red)
}./gradlew build # Build all modules
./gradlew :pulse:build # Build core only
./gradlew :pulse-test:jvmTest # Run tests
./gradlew :demo:run # Run desktop demo app