---
name: android-mvi-viewmodel
description: "A reusable generic BaseViewModel typed on State, Intent and Event for MVI Compose apps: StateFlow state, one-shot events over a rendezvous Channel, a single intent dispatch, and a use-case executor with shared loading and error routing. Use when building or reviewing an MVI ViewModel base, intent-to-state reduction, or one-shot navigation and snackbar events."
metadata:
  risk: safe
  source: multi-agent-pipeline
  date_added: "2026-09-21"
---

# Android MVI BaseViewModel

A generic `BaseViewModel<State, Intent, Event>` that every screen ViewModel
extends. It owns the MVI seam: immutable state as a `StateFlow`, one-shot
effects as events, a single intent entry point, and centralized use-case
execution with shared loading and error routing. Targets Kotlin 2.0+,
`kotlinx.coroutines` 1.8+, and Compose from 2024-2025.

Full copy-ready code (the assembled base class, a concrete screen, and the
lifecycle-aware event collector) lives in
[references/patterns.md](references/patterns.md). Load that file when you need a
complete implementation to adapt; each heading there mirrors a section below.

## Contents

- [The three type parameters](#the-three-type-parameters)
- [State as StateFlow](#state-as-stateflow)
- [One-shot events over a rendezvous channel](#one-shot-events-over-a-rendezvous-channel)
- [Single intent entry point](#single-intent-entry-point)
- [Same-frame input intents](#same-frame-input-intents)
- [Use-case executor](#use-case-executor)
- [Reference-counted loading](#reference-counted-loading)
- [Active job registry](#active-job-registry)
- [Collecting state and events in Compose](#collecting-state-and-events-in-compose)
- [Do's and Don'ts](#dos-and-donts)
- [Related skills](#related-skills)

## The three type parameters

Each screen declares three types and passes an initial state up:

- `State` - an `@Immutable data class`, the complete render model. One per screen.
- `Intent` - a `sealed interface` of everything the UI can ask for (clicks,
  text input, retry, refresh).
- `Event` - a `sealed interface` of one-shot effects the UI consumes exactly
  once (navigate, show snackbar/toast, dismiss keyboard).

State is what the screen *is*; an event is something that *happens* once and
must never replay on rotation.

```kotlin
abstract class BaseViewModel<State, Intent, Event>(
    initialState: State,
) : ViewModel() {
    protected abstract fun handleIntent(intent: Intent)
}
```

## State as StateFlow

State lives in a private `MutableStateFlow` exposed read-only. Never expose the
mutable flow. Update with `.update { copy(...) }` so the read-modify-write is
atomic under concurrent coroutines - `value = value.copy()` races.

```kotlin
private val _state = MutableStateFlow(initialState)
val state: StateFlow<State> = _state.asStateFlow()

protected fun reduce(reducer: State.() -> State) {
    _state.update { it.reducer() }
}
```

Reduction is synchronous and pure: `reduce { copy(query = intent.text) }`. No
suspension, no I/O, no side effects inside the reducer - those belong in the
use-case executor.

## One-shot events over a rendezvous channel

Events use a `Channel(Channel.RENDEZVOUS)` exposed as `receiveAsFlow()`, not a
`SharedFlow(replay = 0)`:

```kotlin
private val _events = Channel<Event>(Channel.RENDEZVOUS)
val events: Flow<Event> = _events.receiveAsFlow()

protected fun sendEvent(event: Event) {
    viewModelScope.launch { _events.send(event) }
}
```

Why rendezvous over `MutableSharedFlow(replay = 0)`:

- **No collector, no loss.** `receiveAsFlow` is a cold, single-consumer stream;
  an event emitted while the screen is in the background suspends the sender
  until collection resumes. `SharedFlow.tryEmit` with no subscriber silently
  drops the event - a navigation that never fires.
- **Delivered once.** A `Channel` fans out to exactly one collector and each
  element is received a single time. `SharedFlow` is multicast, so two active
  collectors (e.g. an accidental double-collect) both fire the navigation.
- **Back-pressure is correct.** `RENDEZVOUS` (zero buffer) hands off directly;
  the emitter waits for the consumer rather than buffering a queue of stale
  toasts.

Use this for navigation, snackbar/toast, and dismiss-keyboard - anything that
must fire once and must not survive a configuration change.

## Single intent entry point

The UI calls exactly one method, `processIntent`. The base logs/guards centrally
and delegates to the subclass `handleIntent`, which is a single `when` over the
sealed `Intent` - exhaustive, so a new intent fails to compile until handled.

```kotlin
fun processIntent(intent: Intent) = handleIntent(intent)

// in a concrete ViewModel
override fun handleIntent(intent: SearchIntent) = when (intent) {
    is SearchIntent.QueryChanged -> reduce { copy(query = intent.text) }
    SearchIntent.Submit -> runSearch()
    SearchIntent.Retry -> runSearch()
}
```

## Same-frame input intents

A base that launches every intent in a coroutine (so handlers can suspend) must
not send all of them to `Dispatchers.Default`. The hop costs a frame, and for an
intent that edits input state (a `TextField` value, a toggle, a radio selection)
that frame is visible: the cursor jumps or the text flickers, because Compose
renders the old value once more before the new one arrives.

Give the intent contract a flag, and run flagged intents on the ViewModel's own
context, which is `Dispatchers.Main.immediate` for `viewModelScope`:

```kotlin
interface UiIntent {
    val immediateDispatch: Boolean get() = false
}

fun processIntent(intent: Intent) {
    val context = if (intent.immediateDispatch) EmptyCoroutineContext else Dispatchers.Default
    viewModelScope.launch(context) { handleIntent(intent) }
}

data class QueryChanged(val text: String) : SearchIntent {
    override val immediateDispatch = true
}
```

The flag only decides where the state write happens. A flagged handler that
needs I/O still moves that work off the main thread with `withContext`. The
same contract can carry per-intent loading flags (show a blocking loader,
allow tap-to-cancel), so the executor below reads them from the intent instead
of taking extra arguments.

## Use-case executor

A single protected helper wraps every suspending call so error handling is not
copy-pasted per screen. It uses `runCatching`, then **rethrows
`CancellationException` before any broad catch** - swallowing it breaks
structured concurrency and leaves the coroutine "running" after cancellation.

```kotlin
protected fun <T> execute(
    showLoading: Boolean = true,
    onError: ((Throwable) -> Unit)? = null,
    block: suspend () -> T,
    onSuccess: (T) -> Unit,
): Job = viewModelScope.launch {
    if (showLoading) incrementLoading()
    try {
        runCatching { block() }
            .onSuccess(onSuccess)
            .onFailure { throwable ->
                if (throwable is CancellationException) throw throwable
                (onError ?: ::routeError)(throwable)
            }
    } finally {
        if (showLoading) decrementLoading()
    }
}
```

To stop a use case from being invoked outside this helper, which would bypass
loading and error routing, declare its `invoke` with a context parameter the
base class provides (`context(executor: UseCaseExecutor) operator fun invoke(...)`,
Kotlin 2.2+ with `-Xcontext-parameters`). Only code inside the executor scope
can call it. Tests call it inside a faked executor with `with(executor) { ... }`.

Error routing has two tiers: a per-call `onError` for screen-specific handling,
falling back to a global `routeError` that maps the throwable to an `Event`
(snackbar) or an error field in state. Define `routeError` once in the base or
override per feature. Because `CancellationException` is rethrown before either
tier, cancellation never reaches the error UI.

## Reference-counted loading

Overlapping use-cases must share one spinner: if two calls each toggled a
boolean, the first to finish would hide the indicator while the second still
runs. Keep an integer count instead - loading is "active" while the count is
above zero.

```kotlin
private val loadingCount = MutableStateFlow(0)
val isLoading: StateFlow<Boolean> =
    loadingCount
        .map { it > 0 }
        .stateIn(viewModelScope, SharingStarted.Eagerly, false)

private fun incrementLoading() = loadingCount.update { it + 1 }
private fun decrementLoading() = loadingCount.update { (it - 1).coerceAtLeast(0) }
```

The `execute` helper drives this in its `try/finally`, so the count is balanced
even when a call throws. Screens observe `isLoading` (or fold it into `State`).

## Active job registry

Track launched jobs by a key so a screen can cancel in-flight work (a new search
supersedes the previous one) and so everything is torn down in `onCleared`.

```kotlin
private val jobs = mutableMapOf<String, Job>()

protected fun launchTracked(key: String, block: suspend () -> Unit) {
    jobs.remove(key)?.cancel()
    jobs[key] = viewModelScope.launch { block() }
}

protected fun cancel(key: String) {
    jobs.remove(key)?.cancel()
}
```

Cancelling `viewModelScope` in `onCleared` (automatic) already stops children;
the registry adds *selective* cancel and de-duplication. Keying a search by
`"search"` makes each keystroke cancel the prior request for free.

## Collecting state and events in Compose

State is collected lifecycle-aware; events are collected in a
`LaunchedEffect` that respects lifecycle so a background screen never consumes a
navigation. Pass **state values and lambdas** to child composables - never the
ViewModel itself, so children stay stateless, previewable, and testable.

```kotlin
@Composable
fun SearchRoute(viewModel: SearchViewModel = hiltViewModel(), onOpenDetail: (Id) -> Unit) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()

    ObserveEvents(viewModel.events) { event ->
        when (event) {
            is SearchEvent.OpenDetail -> onOpenDetail(event.id)
            is SearchEvent.ShowError -> /* show snackbar */ Unit
        }
    }

    SearchScreen(
        state = state,
        isLoading = isLoading,
        onIntent = viewModel::processIntent,
    )
}
```

`ObserveEvents` is a small lifecycle-aware collector (`repeatOnLifecycle`); its
full body is in [references/patterns.md](references/patterns.md).

## Do's and Don'ts

- **Do** expose only `asStateFlow()` / `receiveAsFlow()`; keep the mutable
  backing fields private.
- **Do** update state with `.update { copy(...) }`, never `value = value.copy()`.
- **Do** rethrow `CancellationException` before any broad `catch` or
  `onFailure`.
- **Do** route every suspend call through the executor so loading and errors are
  centralized.
- **Don't** put one-shot effects (navigation, toast) in `State` - they replay on
  rotation.
- **Don't** use `SharedFlow(replay = 0)` for events; a background screen drops
  them.
- **Don't** pass the ViewModel to child composables; pass state + lambdas.
- **Don't** toggle a boolean for loading when calls overlap; count references.
- **Don't** dispatch input-editing intents through `Dispatchers.Default`; flag them for same-frame dispatch.

## Related skills

This skill owns the MVI base-ViewModel seam. For adjacent concerns, defer to:

- **`android-architecture`** - module structure, the api/impl split, and
  nav-graph aggregation that host these ViewModels.
- **`kotlin-coroutines-expert`** - dispatcher selection, structured-concurrency
  and `Flow` operator depth beyond the executor shown here.
- **`android-datastore`** - persisting the state or preferences a ViewModel
  reads and writes.
