Skip to content

bitsycore/pulse-mvi

Repository files navigation

Pulse : A minimal Kotlin Multiplatform MVI library

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.

Modules

Module Description Targets
pulse Core MVI container. Pure Kotlin + coroutines. JVM, Android, iOS, JS, WasmJS
pulse-viewmodel AndroidX ViewModel integration. JVM, Android, iOS, JS, WasmJS
pulse-savedstate Auto-persist state via SavedStateHandle. JVM, Android, iOS, JS, WasmJS
pulse-compose Compose Multiplatform extensions. JVM, Android, iOS, JS, WasmJS
pulse-test Test utilities with synchronous dispatch. JVM, Android, iOS, JS, WasmJS

Dependency graph

pulse-compose     --> pulse
pulse-viewmodel   --> pulse
pulse-savedstate  --> pulse-viewmodel --> pulse
pulse-test        --> pulse

pulse has a single dependency: kotlinx-coroutines-core.

Installation

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>")
        }
    }
}

Quick start

1. Define the contract

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
    }
}

2. Implement the ViewModel

The ViewModel holds the container, defines state reductions, and handles side-effects.

class CounterViewModel : PulseViewModel<UiState, Intent, Effect>(
    containerContract = CounterContract,
    initialState = 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 -> {}
        }
    }
}

3. Connect the UI (Compose)

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel { CounterViewModel() }) {
    val state by viewModel.collectAsStateWithLifecycle()

    viewModel.collectEffect { effect ->
        when (effect) {
            is Effect.ShowToast -> { /* show snackbar */ }
        }
    }

    CounterContent(state, viewModel::dispatch)
}

Core concepts

Container

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()"]
Loading
  • 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 (navigation, toasts, etc.). Effects are buffered but not replayed to late collectors.
  • updateState { copy(...) } -- Convenience for modifying state outside the reducer (e.g., inside callbacks).

DebouncedDispatcher

Standalone debounce engine for rate-limiting rapid input. Not embedded in Container — create one and wire it to any dispatch function.

  • dispatchDebounced(intent, delay) -- Dispatches after a debounce window. Configurable debounce key, skip-if-unchanged, and cross-type sharing.
  • cancel(key) -- Cancels a pending debounce by key and clears its dispatch history.
  • cancelAll() -- Cancels all pending debounces and clears all dispatch history.
  • clearHistory() -- Resets the skipIfUnchanged history without cancelling pending debounces.

ContainerContract

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>()

ContainerHost

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)
}

Effects

Effects are emitted via emitEffect() and delivered through a SharedFlow. They are fire-and-forget one-shot events — each effect is delivered to active collectors only. Effects are not replayed to late subscribers. Use collectEffect or collectEffectWithLifecycle in Compose to handle them.

ViewModel integration

PulseViewModel

Wraps Container in an AndroidX ViewModel. The container's coroutine scope is tied to viewModelScope.

class MyViewModel : PulseViewModel<MyState, MyIntent, MyEffect>(
    containerContract = MyContract,
    initialState = MyState()
) {
    override fun reduce(state: MyState, intent: MyIntent): MyState = ...
    override suspend fun handleIntent(intent: MyIntent) { ... }
}

PulseSavedStateViewModel

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,
        initialState = UiState(),
        savedStateHandle = savedStateHandle,
        serializer = UiState.serializer()
    ) {
    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.

Compose extensions

collectAsStateWithLifecycle

Lifecycle-aware state collection. Defaults to Lifecycle.State.STARTED.

val state by viewModel.collectAsStateWithLifecycle()

collectEffect

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)
    }
}

onLifecycleIntent

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 }
}

onCompositionIntent

Maps Compose composition enter/exit events to intents.

viewModel.onCompositionIntent {
    onEnter { Intent.OnScreenEntered }
    onExit  { Intent.OnScreenExited }
}

ComponentContract

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)
    )
}

Debouncing

DebouncedDispatcher is a standalone debounce engine. Create one and wire it to any dispatch function — typically inside a ViewModel.

Setup

class SearchViewModel : PulseViewModel<UiState, Intent, Effect>(
    containerContract = SearchContract,
    initialState = UiState()
) {

    private val debouncer = DebouncedDispatcher(viewModelScope, ::dispatch)

    fun dispatchDebounced(intent: Intent, delay: Duration) =
        debouncer.dispatchDebounced(intent, delay)

    fun cancelDebounce(key: String) = debouncer.cancel(key)

    override fun reduce(state: UiState, intent: Intent) = when (intent) {
        is Intent.UpdateQuery -> state.copy(query = intent.query)
        is Intent.Search -> state
    }

    override suspend fun handleIntent(intent: Intent) {
        if (intent is Intent.Search) {
            val results = api.search(intent.query)
            updateState { copy(results = results) }
        }
    }
}

Usage in Compose

TextField(
    value = state.query,
    onValueChange = { query ->
        dispatch(Intent.UpdateQuery(query))                                   // immediate UI update
        viewModel.dispatchDebounced(Intent.Search(query), 300.milliseconds)   // debounced API call
    }
)

Debounce options

// Basic: debounce by intent type (default key)
debouncer.dispatchDebounced(Intent.Search(query), delay = 300.milliseconds)

// Custom key: independent debounce per field
debouncer.dispatchDebounced(Intent.UpdateName(name), delay = 300.milliseconds, key = "name")
debouncer.dispatchDebounced(Intent.UpdateEmail(email), delay = 300.milliseconds, key = "email")

// Skip unchanged: drop duplicate intents
debouncer.dispatchDebounced(Intent.Search(query), delay = 300.milliseconds, skipIfUnchanged = true)

// Share across types: different intent types cancel each other
debouncer.dispatchDebounced(Intent.Search(query), delay = 300.milliseconds, shareAcrossTypes = true)

Cancellation and history

// Cancel a specific debounce key (e.g., user cleared the search field)
debouncer.cancel("search")

// Cancel all pending debounces and reset history
debouncer.cancelAll()

// Reset skipIfUnchanged history without cancelling pending debounces
debouncer.clearHistory()

Testing

pulse-test provides a synchronous test container and assertion utilities.

Basic test

@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))
}

Testing effects

@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)
}

Collecting multiple effects

@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)
}

Testing ComponentContract

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)
}

Build

./gradlew build                    # Build all modules
./gradlew :pulse:build             # Build core only
./gradlew :pulse-test:jvmTest      # Run tests
./gradlew :demo:run                # Run desktop demo app

License

MIT

About

MVI library for KMP with Minimal API. Optional ViewModel, Compose, SavedState, and Testing modules.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages