Ali Mansour
Ali Mansour
Ali Mansour
Ali Mansour
Ali Mansour

Senior Software Engineer

Senior Mobile Engineer

Content Creator

Tech Speaker

State Management Evolved: MVI and Kotlin 2.x in Complex Applications

February 20, 2026 Code
State Management Evolved: MVI and Kotlin 2.x in Complex Applications

As Android applications grow in complexity, managing UI state becomes one of the biggest challenges for developers. Over the years, we have transitioned from MVC to MVP, and then to MVVM. Today, Model-View-Intent (MVI) has emerged as a robust solution, especially when paired with Jetpack Compose.

With the release of Kotlin 2.x, we have powerful compiler improvements, better type inference, and strong features that make implementing MVI cleaner than ever. In this article, we will explore how to build a scalable MVI architecture in a modern Android application using Kotlin 2.x.

The Core Problem: Managing State

In modern reactive UIs, the user interface is simply a reflection of the state. If the state is scattered across multiple variables or classes, the UI becomes unpredictable. Bugs like overlapping loading spinners or missing error messages become common.

MVI solves this by enforcing a unidirectional data flow (UDF). 1. The View observes the State. 2. The user interacts with the View, generating an Intent (an action). 3. The ViewModel processes the Intent and updates the State. 4. The View re-renders based on the new State.

Step 1: Defining the Contract (State, Intent, Effect)

To keep things organized, we group our State, Intent, and Effect into a single Contract file.

  • State: A single data class holding all UI data.
  • Intent: A sealed interface representing user actions.
  • Effect: A sealed interface for one-time events (like navigation or showing a Snackbar) that shouldn’t be re-triggered if the screen rotates.

Notice how we use @Stable and immutable collections (PersistentList) below. This is crucial in Jetpack Compose to prevent unnecessary UI recompositions.

// NoteListContract.kt
import androidx.compose.runtime.Stable
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf

@Stable
data class NoteListState(
    val notes: PersistentList<NoteUi> = persistentListOf(),
    val isLoading: Boolean = false,
    val error: String? = null
)

sealed interface NoteListIntent {
    data object OnRefreshClick : NoteListIntent
    data class OnNoteClick(val noteId: String) : NoteListIntent
    data class OnDeleteNote(val noteId: String) : NoteListIntent
}

sealed interface NoteListEffect {
    data class NavigateToDetail(val noteId: String) : NoteListEffect
    data class ShowSnackbar(val message: String) : NoteListEffect
}

Step 2: The ViewModel

The ViewModel is the brain of the operation. It holds the current state using a StateFlow and emits one-time effects using a Channel.

We create a single public function, handleIntent, which the UI will call whenever the user does something.

// NoteListViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

class NoteListViewModel(
    private val repository: NoteRepository
) : ViewModel() {

    // 1. Manage State
    private val _uiState = MutableStateFlow(NoteListState())
    val uiState = _uiState.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000L),
        initialValue = NoteListState()
    )

    // 2. Manage One-Time Effects
    private val _effect = Channel<NoteListEffect>(Channel.BUFFERED)
    val effect = _effect.receiveAsFlow()

    // 3. Handle Intents
    fun handleIntent(intent: NoteListIntent) {
        when (intent) {
            is NoteListIntent.OnRefreshClick -> loadNotes()
            is NoteListIntent.OnNoteClick -> {
                viewModelScope.launch {
                    _effect.send(NoteListEffect.NavigateToDetail(intent.noteId))
                }
            }
            is NoteListIntent.OnDeleteNote -> deleteNote(intent.noteId)
        }
    }

    private fun loadNotes() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            // Fetch from repository
            val result = repository.getNotes()
            if (result.isSuccess) {
                _uiState.update { 
                    it.copy(
                        notes = result.getOrNull()?.toPersistentList() ?: persistentListOf(),
                        isLoading = false
                    ) 
                }
            } else {
                _uiState.update { it.copy(isLoading = false) }
                _effect.send(NoteListEffect.ShowSnackbar("Failed to load notes"))
            }
        }
    }
    
    private fun deleteNote(noteId: String) {
        // Implementation for deleting a note
    }
}

Step 3: Surviving Process Death

Sometimes, the Android operating system kills your app in the background to free up memory. When the user returns, they expect their typed text to still be there.

We can use SavedStateHandle directly in the ViewModel to store and restore critical user input automatically.

class NoteEditorViewModel(
    private val savedStateHandle: SavedStateHandle,
    private val repository: NoteRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(
        NoteEditorState(
            // Restore state if process was killed!
            title = savedStateHandle["title"] ?: "",
            body = savedStateHandle["body"] ?: ""
        )
    )
    val uiState = _uiState.asStateFlow()

    fun handleIntent(intent: NoteEditorIntent) {
        when (intent) {
            is NoteEditorIntent.OnTitleChange -> {
                // Save state automatically
                savedStateHandle["title"] = intent.title
                _uiState.update { it.copy(title = intent.title) }
            }
            // ...
        }
    }
}

Step 4: The Compose UI Layer

A great practice in Jetpack Compose is splitting your screen into two parts: 1. Stateful Screen: Injects the ViewModel, collects the state, and listens to effects. 2. Stateless Content: Only accepts raw data and callbacks. This makes it incredibly easy to preview and test.

// NoteListScreen.kt
import androidx.compose.runtime.*
import androidx.lifecycle.compose.collectAsStateWithLifecycle

// 1. Stateful Screen Entry Point
@Composable
fun NoteListScreen(
    onNavigateToDetail: (String) -> Unit,
    viewModel: NoteListViewModel // Injected via Koin or Hilt
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // Listen to one-time effects securely
    ObserveAsEvents(viewModel.effect) { effect ->
        when (effect) {
            is NoteListEffect.NavigateToDetail -> onNavigateToDetail(effect.noteId)
            is NoteListEffect.ShowSnackbar -> { /* Show snackbar logic */ }
        }
    }

    // Pass data down
    NoteListContent(
        state = uiState,
        handleIntent = viewModel::handleIntent
    )
}

// 2. Stateless UI Component
@Composable
fun NoteListContent(
    state: NoteListState,
    handleIntent: (NoteListIntent) -> Unit
) {
    if (state.isLoading) {
        CircularProgressIndicator()
    } else {
        LazyColumn {
            items(state.notes, key = { it.id }) { note ->
                NoteItem(
                    note = note,
                    onClick = { handleIntent(NoteListIntent.OnNoteClick(note.id)) }
                )
            }
        }
    }
}

// 3. Easy to Preview!
@Preview
@Composable
private fun NoteListContentPreview() {
    NoteListContent(
        state = NoteListState(isLoading = true),
        handleIntent = {}
    )
}

Note: ObserveAsEvents is a helper function typically used to safely collect flows tied to the lifecycle

Conclusion

By combining MVI with Kotlin 2.x and Jetpack Compose, we create an architecture that is highly predictable and easy to test.

Defining a strict Contract separates our business logic from our UI completely. The ViewModel safely manages the state, and the Compose UI simply renders whatever state it receives. By splitting stateful and stateless components, we ensure our UI is easy to preview and immune to tricky bugs.

As your apps grow in 2026 and beyond, investing time into a clean MVI architecture will pay off massively in maintainability and developer speed.

Tags:
Write a comment

Verified by MonsterInsights