Skip to Content
Course content

185: MVI Architecture Pattern in Kotlin

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By the time you get to MVI (Model-View-Intent), you've probably already wrestled with MVVM. While MVVM is great, it can sometimes lead to a "state explosion" where you have five different LiveData or StateFlow objects in your ViewModel, and you're left guessing which one updated first. MVI is my preferred way to stop that chaos. It's all about Unidirectional Data Flow (UDF).

Wait, how is MVI actually different from MVVM?

In MVVM, the View usually calls a function in the ViewModel, and the ViewModel updates one or more observables. It's a bit of a free-for-all. In MVI, we treat everything as a cycle. The View doesn't just "call a function"; it emits an Intent. The ViewModel takes that Intent, processes it, and spits out a single, immutable State object. The View then simply renders whatever that state tells it to.

Think of it like a vending machine. You don't go inside and move the gears yourself (MVVM). You press a button (Intent), the machine processes the request, and it drops a soda (State). You just react to the soda appearing.

How do I actually structure the Intents and State in Kotlin?

I always recommend using sealed classes or sealed interfaces here. It makes your code type-safe and forces you to handle every possible action in a when expression. Let's use a Crypto Price Tracker as an example.

// The "What happened" - User actions
sealed class CryptoIntent {
    object RefreshPrices : CryptoIntent()
    data class SearchCoin(val query: String) : CryptoIntent()
    data class ToggleFavorite(val coinId: String) : CryptoIntent()
}

// The "What to show" - Single source of truth
data class CryptoState(
    val coins: List<Coin> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val searchQuery: String = ""
)

Notice how CryptoState is a data class. We never modify it directly; we use .copy() to create a new version of the state. This is key for debugging—if you log your state changes, you can see exactly how the app evolved step-by-step.

Where does the logic go to turn an Intent into a State?

This happens in your ViewModel. You'll typically have a MutableStateFlow that holds the current state and a function to "handle" the intents. I like to keep the "reducer" logic (the part that decides the new state) separate from the business logic (the API calls).

class CryptoViewModel : ViewModel() {
    private val _state = MutableStateFlow(CryptoState())
    val state: StateFlow<CryptoState> = _state.asStateFlow()

    fun handleIntent(intent: CryptoIntent) {
        when (intent) {
            is CryptoIntent.RefreshPrices -> fetchCoins()
            is CryptoIntent.SearchCoin -> {
                _state.update { it.copy(searchQuery = intent.query) }
                filterCoins()
            }
            is CryptoIntent.ToggleFavorite -> toggleCoin(intent.coinId)
        }
    }

    private fun fetchCoins() {
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true) }
            try {
                val coins = repository.getLatestPrices()
                _state.update { it.copy(coins = coins, isLoading = false) }
            } catch (e: Exception) {
                _state.update { it.copy(errorMessage = e.message, isLoading = false) }
            }
        }
    }
}

What about "one-time" events, like showing a Toast or navigating?

This is the biggest "gotcha" in MVI. If you put showToast = true in your state, the toast will pop up again every time the screen rotates or the state updates for some other reason. We don't want that.

The fix is to introduce Side Effects (sometimes called Events). These are things that happen once and are then forgotten. I usually implement this using a Channel.

sealed class CryptoEffect {
    data class ShowSnackbar(val message: String) : CryptoEffect()
    object NavigateToDetails : CryptoEffect()
}

// Inside ViewModel...
private val _effect = Channel<CryptoEffect>()
val effect = _effect.receiveAsFlow()

private fun handlePaymentError() {
    viewModelScope.launch {
        _effect.send(CryptoEffect.ShowSnackbar("Payment Failed!"))
    }
}

Now your View observes the state for the UI layout and the effect flow for those "fire-and-forget" actions. It keeps the state clean and the behavior predictable.




📋 Practical Task

Exercise: Implementing a Real-time User Filter with MVI

You are building a contact list app. Your goal is to implement a search filter using the MVI pattern. Follow these requirements:

  • Define the State: Create a UserState data class containing a list of User objects, a String for the current search query, and a Boolean for the loading status.
  • Define the Intents: Create a UserIntent sealed class with two actions: UpdateQuery(val query: String) and ClearFilter.
  • Build the ViewModel:
    • Use a MutableStateFlow to manage the UserState.
    • Implement a handleIntent(intent: UserIntent) function.
    • When UpdateQuery is received, update the state's query and filter the user list based on that query.
    • When ClearFilter is received, reset the query to an empty string and restore the full user list.
  • Side Effect: Add a UserEffect sealed class and a Channel. If a user searches for a name that doesn't exist in the list, send a ShowNoResultsFound effect.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.