MVI and Unidirectional Data Flow in Jetpack Compose

Quick answer: In Compose, use unidirectional data flow: immutable UiState flows down into the UI, and user actions flow up to one state owner. “MVI” is one naming convention for that loop—state, intent/action, and sometimes effects—not a library or a requirement to put every behavior behind one giant dispatcher.

Compose is naturally compatible with UDF because composables accept values and expose callbacks. Android’s Compose architecture guidance defines the loop as events flowing up, a state holder updating state, and the updated state flowing down to render. The strongest design choice is the single source of truth, not whether your action type is named Intent, Action, or Event.

Separate the three kinds of data

Use different models for values that must be rendered, requests for a state change, and commands that leave the rendering model.

KindDirectionExampleLifetime
UiStateDown to UIquery, loading, visible error, result listDurable while the screen should render it
Action / intentUp to state ownertext changed, retry tapped, result selectedA request to do work
EffectOut from state ownernavigate, show snackbar, launch pickerOne-time command with an explicit delivery policy

Do not put a navigation command or snackbar request in durable state just because it was produced by an action. The detailed treatment of that boundary is in UI State, UI Events, and One-Time Effects in Compose.

Start with an immutable state and explicit actions

The state should describe what a user can see now. An action names what happened without letting a child composable change a field directly.

data class SearchUiState(
    val query: String = "",
    val isLoading: Boolean = false,
    val results: List<ArticleUi> = emptyList(),
    val errorMessage: String? = null,
)

sealed interface SearchAction {
    data class QueryChanged(val query: String) : SearchAction
    data object SearchSubmitted : SearchAction
    data object RetryClicked : SearchAction
    data class ResultClicked(val articleId: String) : SearchAction
}

sealed interface SearchEffect {
    data class OpenArticle(val articleId: String) : SearchEffect
}

This is a reducer-style MVI vocabulary. A smaller screen can expose onQueryChanged() and onRetry() methods instead; both approaches still follow UDF when one owner updates immutable state. Use an action type when it makes the screen contract clearer or helps centralize related transitions, not simply because the app uses the label “MVI.”

Reduce actions in one state owner

The ViewModel receives actions, calculates a replacement state, and triggers asynchronous work outside the UI. The reducer below is a pure function for synchronous transitions, which makes its behavior easy to test.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

class SearchViewModel(
    private val repository: ArticleRepository,
) : ViewModel() {
    private val _uiState = MutableStateFlow(SearchUiState())
    val uiState = _uiState.asStateFlow()
    private val effectsChannel = Channel<SearchEffect>(Channel.BUFFERED)
    val effects = effectsChannel.receiveAsFlow()

    fun onAction(action: SearchAction) {
        when (action) {
            is SearchAction.QueryChanged -> update(action)
            SearchAction.SearchSubmitted,
            SearchAction.RetryClicked -> search()
            is SearchAction.ResultClicked -> openArticle(action.articleId)
        }
    }

    private fun update(action: SearchAction.QueryChanged) {
        _uiState.update { state ->
            reduce(state, action)
        }
    }

    private fun search() = viewModelScope.launch {
        val query = uiState.value.query
        _uiState.update { it.copy(isLoading = true, errorMessage = null) }

        repository.search(query)
            .onSuccess { results ->
                _uiState.update {
                    it.copy(isLoading = false, results = results)
                }
            }
            .onFailure {
                _uiState.update {
                    it.copy(isLoading = false, errorMessage = "Try again")
                }
            }
    }

    private fun openArticle(articleId: String) = viewModelScope.launch {
        effectsChannel.send(SearchEffect.OpenArticle(articleId))
    }
}

private fun reduce(
    state: SearchUiState,
    action: SearchAction.QueryChanged,
): SearchUiState = state.copy(
    query = action.query,
    errorMessage = null,
)

ArticleRepository and ArticleUi are illustrative application types. The pattern matters: update the private state holder, expose only a read-only stream, and replace the model instead of mutating it in place. Immutable UI State and Stability in Jetpack Compose explains why this supports both correct ownership and clearer recomposition behavior.

Collect once at the route boundary

The route owns the ViewModel integration. The screen only receives state and a callback, so it remains easy to preview and test.

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun SearchRoute(
    viewModel: SearchViewModel = viewModel(),
    onOpenArticle: (String) -> Unit,
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    LaunchedEffect(viewModel.effects) {
        viewModel.effects.collect { effect ->
            when (effect) {
                is SearchEffect.OpenArticle -> onOpenArticle(effect.articleId)
            }
        }
    }

    SearchScreen(
        uiState = uiState,
        onAction = viewModel::onAction,
    )
}

@Composable
fun SearchScreen(
    uiState: SearchUiState,
    onAction: (SearchAction) -> Unit,
) {
    OutlinedTextField(
        value = uiState.query,
        onValueChange = { onAction(SearchAction.QueryChanged(it)) },
        label = { Text("Search") },
    )

    Button(onClick = { onAction(SearchAction.SearchSubmitted) }) {
        Text("Search")
    }
}

LaunchedEffect collects the one-time navigation command in a composition-bound coroutine. For a different product policy, navigation can instead be derived from durable route state when restoration is intended. Do not navigate directly from the rendering body.

For Android StateFlow, collectAsStateWithLifecycle() is the recommended lifecycle-aware collection boundary. See collectAsStateWithLifecycle with StateFlow for the dependency and active-lifecycle behavior.

Effects require a delivery policy

MVI examples often add an “effect,” “news,” or “side-effect” stream. That can be correct, but it is not free. Decide before choosing Channel, SharedFlow, or a clearable state:

  • Should the command survive a screen recreation?
  • What happens when no UI is collecting?
  • Can more than one collector receive it?
  • Is the command really transient, or should the next screen be derived from durable state?

For example, a selected article ID is often durable UI or navigation state. A “saved” snackbar is often transient. A Channel can be a reasonable choice for exactly one UI consumer, while a state-driven design can be better when restoration is intended. Test the chosen policy; there is no universally correct effect stream.

MVI does not mean one architecture for every screen

Screen complexityUseful shape
Local UI-only toggleHoisted value plus onValueChange may be enough.
Standard form or list screenImmutable UiState and a few explicit ViewModel event methods are usually readable.
Dense workflow with many related transitionsA sealed action type and pure reducer can make valid transitions easier to inspect and test.
Cross-screen or external commandAdd a carefully defined effect or navigation boundary.

Avoid creating a sealed action hierarchy for a two-button widget if direct callbacks communicate the same contract more clearly. Conversely, do not scatter state mutation across multiple children when a complex workflow needs a traceable, testable transition model.

Test the loop in layers

  1. Unit-test reduce() with an initial state and one action; assert the complete resulting state.
  2. Test the ViewModel with a fake repository: loading, success, failure, retry, and cancellation behavior.
  3. Test SearchScreen with fixed state and a callback that captures each SearchAction.
  4. Test navigation at the NavHost boundary, not inside a leaf UI component. Testing Navigation Compose shows the UI-driven approach.

This split finds the right failure quickly: reducer failures are state-transition problems, screen failures are rendering or semantics problems, and graph failures are navigation wiring problems.

Common mistakes

Calling every callback an intent but changing state in the UI

An action type does not create UDF on its own. State must still have a clear owner, and the UI must request changes rather than keep competing copies of the same screen state.

Treating every error as a one-time effect

If an error should remain visible until the user fixes it or retries, model it in UiState. Use an effect only for a command that should happen once under a documented delivery policy.

Sending the entire ViewModel to child composables

It hides inputs and makes previews and tests harder. Pass state and narrowly scoped callbacks from a route or screen-level container.

Creating a reducer that performs UI work

Reducers should calculate state. Navigation, snackbar presentation, and launching external UI belong in controlled effect handling, while repositories and use cases own business operations.

FAQ

Is MVI required for Compose?

No. Compose benefits from UDF, but the platform does not require a specific MVI library, action naming scheme, or a single dispatch() method.

Should I use StateFlow or mutableStateOf in a ViewModel?

Both can support UDF when the state is observable and the ViewModel is the owner. StateFlow is a common choice when state is derived from flows or needs coroutine-friendly composition; choose the smallest observable holder that keeps ownership clear.

Where should business logic run?

The ViewModel coordinates UI logic and calls business or data-layer code. Keep repository I/O and domain rules out of composables, and keep rendering side-effect free.