mirror of
https://github.com/bitsycore/pulse-mvi.git
synced 2026-10-05 12:07:26 +00:00
514 lines
15 KiB
Markdown
514 lines
15 KiB
Markdown

|
|
# 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 repository (in `settings.gradle.kts` or your repositories block):
|
|
|
|
```kotlin
|
|
repositories {
|
|
maven("https://maven.bitsycore.com/releases")
|
|
}
|
|
```
|
|
|
|
Artifacts are also published to [GitHub Packages](https://github.com/bitsycore/pulse-mvi/packages)
|
|
as a fallback. Note that GitHub Packages requires authentication, even for public packages:
|
|
|
|
```kotlin
|
|
repositories {
|
|
maven("https://maven.pkg.github.com/bitsycore/pulse-mvi") {
|
|
credentials {
|
|
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
|
|
password = providers.gradleProperty("gpr.token").orNull ?: System.getenv("GITHUB_TOKEN")
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Then add the modules you need to your `build.gradle.kts`:
|
|
|
|
```kotlin
|
|
kotlin {
|
|
sourceSets {
|
|
commonMain.dependencies {
|
|
implementation("com.bitsycore.lib:pulse:<version>")
|
|
implementation("com.bitsycore.lib:pulse-viewmodel:<version>")
|
|
implementation("com.bitsycore.lib:pulse-compose:<version>")
|
|
implementation("com.bitsycore.lib:pulse-savedstate:<version>")
|
|
}
|
|
commonTest.dependencies {
|
|
implementation("com.bitsycore.lib: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.
|
|
|
|
```kotlin
|
|
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.
|
|
|
|
```kotlin
|
|
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)
|
|
|
|
```kotlin
|
|
@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.
|
|
|
|
```mermaid
|
|
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 (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.
|
|
|
|
```kotlin
|
|
object MyContract : ContainerContract<MyState, MyIntent, MyEffect>()
|
|
```
|
|
|
|
### ContainerHost
|
|
|
|
Interface exposing the public API of a container:
|
|
|
|
```kotlin
|
|
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`.
|
|
|
|
```kotlin
|
|
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`.
|
|
|
|
```kotlin
|
|
@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:
|
|
|
|
```kotlin
|
|
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`.
|
|
|
|
```kotlin
|
|
val state by viewModel.collectAsStateWithLifecycle()
|
|
```
|
|
|
|
### collectEffect
|
|
|
|
Lifecycle-aware one-shot effect collector.
|
|
|
|
```kotlin
|
|
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.
|
|
|
|
```kotlin
|
|
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.
|
|
|
|
```kotlin
|
|
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.
|
|
|
|
```kotlin
|
|
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:
|
|
|
|
```kotlin
|
|
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:
|
|
|
|
```kotlin
|
|
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
|
|
|
|
```kotlin
|
|
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
|
|
|
|
```kotlin
|
|
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
|
|
|
|
```kotlin
|
|
// 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
|
|
|
|
```kotlin
|
|
// 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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
@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.
|
|
|
|
```kotlin
|
|
@Test
|
|
fun colorPickerClampsValues() {
|
|
val state = ColorPickerComponent.reduce(
|
|
ColorPickerComponent.initialState,
|
|
ColorPickerComponent.Intent.SetRed(1.5f)
|
|
)
|
|
assertEquals(1f, state.red)
|
|
}
|
|
```
|
|
|
|
## Build
|
|
|
|
```bash
|
|
./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](LICENSE.md)
|