Ali Mansour
Ali Mansour
Ali Mansour
Ali Mansour
Ali Mansour

Senior Software Engineer

Senior Mobile Engineer

Content Creator

Tech Speaker

Inside MVI: Managing State Like a Pro in Jetpack Compose

October 20, 2025 Code
Inside MVI: Managing State Like a Pro in Jetpack Compose

“Every recomposition is innocent until proven guilty.”
That’s the mindset you need when managing UI state in Jetpack Compose — and the 
MVI (Model–View–Intent) pattern is your best ally.

If you’ve built a few Compose apps using MVVM, you’ve probably run into issues like:

  • “My button click runs twice after rotation.”
  • “My snackbar keeps showing again after returning to the screen.”
  • “Why is Compose recomposing everything when only one field changes?”

These aren’t random bugs—they’re symptoms of unstructured state and effect management.
Let’s fix that with a modern, scalable MVI pattern that plays perfectly with Compose and Kotlin Flows.

What Exactly Is MVI?

MVI = Model — View — Intent

It’s a pattern inspired by Redux and Unidirectional Data Flow (UDF).
Here’s the flow in one line:

User Intent → Reducer → New State → UI Render → User Intent ...

The key idea is immutability and predictability:

  • The Model (State) represents the entire UI snapshot.
  • The View is a pure function of that state.
  • The Intent describes what the user wants to do (actions).

Everything flows one way — no callbacks mutating state behind your back.

MVI vs. MVVM — Why It Matters

ConceptMVVMMVI
StateMultiple LiveData/Flows One immutable State object
ActionsDirect ViewModel callsExplicit Intents
UpdatesImperativeReducer-based, declarative
TestingMultiple streamsSingle reducer
EffectsMixed into stateSeparate “effects” channel

MVVM works well for smaller apps.
But MVI gives you predictability in large, reactive UIs — the kind we build with Jetpack Compose.

Let’s Build a Practical Example

The Counter App (Done Right)

We’ll start small and grow it into a fully reactive, testable architecture.

Step 1 — Define State, Intents, and Effects

We’ll model everything as immutable data.

@Immutable
data class CounterState(
    val value: Int = 0,
    val loading: Boolean = false,
    val error: String? = null
)

sealed interface CounterIntent {
    data object Increment : CounterIntent
    data object Decrement : CounterIntent
    data object LoadInitial : CounterIntent
}

sealed interface CounterEffect {
    data class ShowToast(val message: String) : CounterEffect
}

✅ @Immutable helps Compose skip unnecessary recompositions.
✅ The entire UI is defined by one single state object.

Step 2 — Create the ViewModel (Reducer + Effect Channels)

Here’s the “brain” of the feature — it transforms intents → new states.

class CounterViewModel(
    private val repository: CounterRepository
) : ViewModel() {

    private val _state = MutableStateFlow(CounterState())
    val state: StateFlow<CounterState> = _state.asStateFlow()

    private val _effects = MutableSharedFlow<CounterEffect>()
    val effects = _effects.asSharedFlow()

    fun onIntent(intent: CounterIntent) {
        when (intent) {
            CounterIntent.Increment -> updateValue(+1)
            CounterIntent.Decrement -> updateValue(-1)
            CounterIntent.LoadInitial -> loadValue()
        }
    }

    private fun updateValue(delta: Int) {
        _state.update { current -> current.copy(value = (current.value + delta).coerceAtLeast(0)) }
    }

    private fun loadValue() = viewModelScope.launch {
        _state.update { it.copy(loading = true, error = null) }
        runCatching { repository.getInitialValue() }
            .onSuccess { v ->
                _state.update { it.copy(loading = false, value = v) }
                _effects.emit(CounterEffect.ShowToast("Initial value loaded"))
            }
            .onFailure { e ->
                _state.update { it.copy(loading = false, error = e.message) }
                _effects.emit(CounterEffect.ShowToast("Error loading value"))
            }
    }
}

Notice how everything happens inside reducers, never from the UI directly.
No callbacks, no spaghetti code — just clear, predictable flow.

Step 3 — Build the Compose UI

The UI should be a pure function of state.
It observes, renders, and dispatches intents — nothing more.

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

    LaunchedEffect(Unit) {
        viewModel.effects.collect { effect ->
            when (effect) {
                is CounterEffect.ShowToast -> 
                    Toast.makeText(ctx, effect.message, Toast.LENGTH_SHORT).show()
            }
        }
    }

    Column(
        Modifier.fillMaxSize().padding(24.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        if (state.loading) {
            CircularProgressIndicator()
        } else {
            Text(text = "Count: ${state.value}", style = MaterialTheme.typography.headlineMedium)
            Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
                Button(onClick = { viewModel.onIntent(CounterIntent.Decrement) }) { Text("-") }
                Button(onClick = { viewModel.onIntent(CounterIntent.Increment) }) { Text("+") }
            }
        }

        state.error?.let { Text(it, color = Color.Red) }
    }
}

Compose re-renders automatically when state changes.
No need to manually update views — the data drives the UI.

Step 4 — Handle Side Effects Properly

One of the most common Compose mistakes is triggering side effects (like Toasts, Snackbars, or navigation) directly inside Composables.
That leads to duplicate triggers when recomposition happens.

The right way: collect them in LaunchedEffect.

LaunchedEffect(Unit) {
    viewModel.effects.collect { effect ->
        if (effect is CounterEffect.ShowToast)
            Toast.makeText(ctx, effect.message, Toast.LENGTH_SHORT).show()
    }
}

This ensures each effect runs once per emission, not per recomposition.

Step 5 — Add a Repository for Realism

Let’s simulate a real data layer.

interface CounterRepository {
    suspend fun getInitialValue(): Int
}

class FakeCounterRepository : CounterRepository {
    override suspend fun getInitialValue(): Int {
        delay(500)
        return (1..10).random()
    }
}

Inject this with Hilt or Koin and you’re production-ready.

Step 6 — Test the Reducer & Effects

Testing MVI is straightforward because your logic is pure and side effects are explicit.

@OptIn(ExperimentalCoroutinesApi::class)
class CounterViewModelTest {

    private val repo = object : CounterRepository {
        override suspend fun getInitialValue() = 42
    }

    @Test
    fun `load initial value updates state and emits toast`() = runTest {
        val viewModel = CounterViewModel(repo)
        val effects = mutableListOf<CounterEffect>()

        val job = launch { viewModel.effects.toList(effects) }

        viewModel.onIntent(CounterIntent.LoadInitial)
        advanceUntilIdle()

        assertEquals(42, viewModel.state.value.value)
        assertTrue(effects.any { it is CounterEffect.ShowToast })

        job.cancel()
    }
}

✅ No need to mock Android components.
✅ State testing becomes predictable and isolated.

Step 7 — Scaling the Pattern

In real-world apps:

  • Each feature gets its own StateIntentEffect, and ViewModel.
  • Shared logic (auth, analytics, persistence) lives in domain/use cases.
  • You can unit test reducers and mock repositories easily.
  • The same structure scales to Compose Multiplatform.

A typical app might look like this:

:feature:login
:feature:profile
:core:ui
:core:domain
:core:data

Each module is independent, tested, and reusable.

Performance & Stability Tips

ProblemMVI Solution
Recomposition stormsUse `@Stable` / `@Immutable` state models
State loss after process deathIntegrate with `SavedStateHandle`
Duplicate effectsCollect effects in `LaunchedEffect(Unit)`
Large state objectsSplit into smaller reducers
Complex business logicMove to UseCases

Compose’s performance heavily depends on stable state references — immutable MVI states are a natural fit.

Why MVI Fits Compose Perfectly

Jetpack Compose is declarative, not imperative—and so is MVI.
They share the same philosophy: “UI = function(state)”.

MVI gives you:

✅ Predictable behavior
✅ Easier debugging
✅ Immutable, replayable state
✅ Full testability
✅ Seamless lifecycle handling

Wrapping Up

With MVI, your Android app becomes predictable, testable, and scalable.
You stop chasing recomposition bugs and start thinking in data flow.

Compose + MVI + Kotlin Flows = A powerhouse combination for modern Android apps.

TL;DR

  • MVI structures your app around intentsreducers, and immutable state.
  • Compose loves predictable state flow.
  • Separate state from effects for clean architecture.
  • Test your reducers in isolation.

Author’s Note

If you found this useful, follow me for the full “Mastering Modern Android Development” series.

Stay tuned — we’re leveling up your Compose architecture skills one layer at a time. Let’s make Android development cleaner, faster, and more fun — together.

Tags:
Write a comment

Verified by MonsterInsights