How to master MVI with Reducers in Android 

3 min read

Introduction

State management is one of the most critical challenges in Android development. Here at zen8labs, my colleagues and I adopt the Model-View-Intent (MVI) architecture to keep our applications scalable, maintainable, and predictable. One of the core components of MVI is the Reducer, responsible for transforming events into state updates in a pure and predictable manner. In my blog for you, we will take a deep dive into: 

  • What is an MVI? 
  • Understanding Reducers in MVI 
  • Implementing MVI with Jetpack Compose (Example: Counter App) 

By the end of this tutorial, you will have a fully functional Counter App, where users can increment, decrement, and reset a counter using MVI with Jetpack Compose.

What is an MVI?

MVI is a unidirectional state management pattern where: 

  • User actions trigger events. 
  • Reducers process events and update the state. 
  • Side Effects handles API calls, database operations, and UI interactions.

Why use MVI?

  • Predictability – The state is updated in a controlled way. 
  • Testability – Since reducers are pure functions, they can be easily tested. 
  • Maintainability – UI logic and business logic are separated. 
zen8labs How to master MVI with reducers in Android

Understanding reducers in MVI

A Reducer is a pure function that: 

  • Takes the current state and events. 
  • Returns to a new, immutable state. 
  • Optionally triggers side effects. 

A Reducer does not: 

  • Modify existing state directly. 
  • Perform network or database operations. 
  • Interact with UI components. 

By keeping reducers pure and predictable, debugging becomes easier, and testability is improved. 

Example: Counter App using MVI with jetpack compose

We will build a Counter App where users can: 

  • Increment the Counter 
  • Decrement the Counter 
  • Reset the Counter

1. Define the ViewState

The CounterViewState holds the current state of the counter. 
@Immutable 
data class CounterViewState( 
    val count: Int = 0 
) 

2. Define events

Each user action in MVI is represented as an event.

@Immutable 
sealed class CounterEvent { 
    data object Increment : CounterEvent() 
    data object Decrement : CounterEvent() 
    data object Reset : CounterEvent() 
}

3. Define Side Effects

Side effects are one-time actions like showing a Snackbar.

Side effects are one-time actions like showing a Snackbar. 
@Immutable 
sealed class CounterSideEffect { 
    data class ShowErrorPopup(val message: String) : CounterSideEffect() 
} 

Why use SideEffects? 

  • They separate UI interactions from state updates. 
  • This keeps the Reducer pure and testable

4. Implement the Reducer

A Reducer takes the current state and an event, then returns a new state.

    CounterReducer {

    fun reduce( 
        previousState: CounterViewState, 
        event: CounterEvent 
    ): Pair<CounterViewState, CounterSideEffect?> { 
        return when (event) { 
            is CounterEvent.Increment -> { 
                previousState.copy(count = previousState.count + 1) to CounterSideEffect.LogEvent("Incremented to ${previousState.count + 1}") 
            } 
            is CounterEvent.Decrement -> { 
                if (previousState.count > 0) { 
                    previousState.copy(count = previousState.count - 1) to CounterSideEffect.LogEvent("Decremented to ${previousState.count - 1}") 
                } else { 
                    previousState to CounterSideEffect.ShowErrorPopup("Count cannot be negative") 
                } 
            } 
            is CounterEvent.Reset -> { 
                CounterViewState(count = 0) to CounterSideEffect.LogEvent("Counter reset to 0") 
            } 
            is CounterEvent.Error -> { 
                previousState.copy() to CounterSideEffect.ShowErrorPopup(event.message) 
            } 
        } 
    } 
} 

5. Implement the ViewModel

The ViewModel acts as the bridge between UI and Reducer.

class CounterViewModel : ViewModel() { 
    private val reducer = CounterReducer() 
    private val _state = MutableStateFlow(CounterViewState()) 
    val state = _state.asStateFlow() 
    private val _sideEffect = Channel<CounterSideEffect>(Channel.BUFFERED) 
    val sideEffect = _sideEffect.receiveAsFlow() 
    fun sendEvent(event: CounterEvent) { 
        val (newState, effect) = reducer.reduce(_state.value, event) 
        _state.value = newState 
        effect?.let { sendSideEffect(it) } 
    } 
    private fun sendSideEffect(effect: CounterSideEffect) { 
        _sideEffect.trySend(effect).onFailure { 
            println("⚠️ Failed to send SideEffect: ${it?.message}") 
        } 
    } 
    override fun onCleared() { 
        super.onCleared() 
        _sideEffect.close() 
    }} 

Why should you use MutableStateFlow for State? – It ensures real-time UI updates when the state changes. 

Why should you use Channel for SideEffects? – SideEffects should only be received once, and Channel ensures that. 

6. Implement the Jetpack Compose UI

Finally, we have created a Composable UI that listens to ViewModel state and side effects. 

@OptIn(ExperimentalMaterial3Api::class) 
    @Composable 
    fun CounterScreen() { 
        val snackBarHostState = remember { SnackbarHostState() } 
        val state by viewModel.state.collectAsState() 
 
        LaunchedEffect(Unit) { 
            viewModel.sideEffect.collect { effect -> 
                when (effect) { 
                    is CounterSideEffect.ShowErrorPopup -> { 
                        snackBarHostState.showSnackbar(effect.message) 
                    } 
                } 
            } 
        } 
 
        Scaffold( 
            topBar = { 
                TopAppBar(title = { Text("Counter App") }) 
            }, 
            snackbarHost = { SnackbarHost(snackBarHostState) } 
        ) { padding -> 
            Box( 
                Modifier 
                    .fillMaxSize() 
                    .padding(padding) 
            ) { 
                Column( 
                    modifier = Modifier.fillMaxSize(), 
                    horizontalAlignment = Alignment.CenterHorizontally, 
                    verticalArrangement = Arrangement.Center 
                ) { 
                    Text(text = "Count: ${state.count}", fontSize = 24.sp) 
                    Spacer(modifier = Modifier.height(16.dp)) 
                    Row { 
                        Button(onClick = { viewModel.sendEvent(CounterEvent.Increment) }) { 
                            Text("Increment") 
                        } 
                        Spacer(modifier = Modifier.width(16.dp)) 
                        Button(onClick = { viewModel.sendEvent(CounterEvent.Decrement) },) { 
                            Text("Decrement") 
                        } 
                        Spacer(modifier = Modifier.width(16.dp)) 
                        Button(onClick = { viewModel.sendEvent(CounterEvent.Reset) }) { 
                            Text("Reset") 
                        } 
                    } 
                } 
            } 
        } 
    } 

Conclusion

Leveraging MVI architecture to build scalable, maintainable, and predictable Android applications is something that we do daily at zen8labs. By embracing unidirectional data flow, we ensure that our apps remain robust and testable while keeping the UI and business logic neatly separated. 

With MVI, you now have a powerful and structured way to handle state in Jetpack Compose. Whether you’re working on a simple counter app or a complex enterprise project, this approach scales effortlessly. However if your next project requires the best in the business to help you, then ask us here, together we can create an awesome project together!

Duc Duong – Mobile Engineer 

Related posts

Flutter has transformed the landscape of cross-platform app development. Learn in this blog how to get the best out of Flutter for your benefit and enjoyment.
4 min read
What is an OkHttp Interceptor? They are a powerful tools that allow for calls to flow efficiently. Our new blog gives quick insights into all things interceptor.
3 min read
What is a demo in Swift package? Find out the ways that having a demo in your Swift package can help make your project easier to maintain, update and reuse.
5 min read