Skip to content
Educora
Advanced22 min11 / 14

State and architecture: ViewModel and MVVM

Keep state in Compose with `remember` and `mutableStateOf`, hoist it up, move the screen's data into a ViewModel with StateFlow, and structure the app in the MVVM style with unidirectional data flow.

Check yourself
In this lesson you will learn
  • Explain the difference between remember, rememberSaveable and mutableStateOf
  • Hoist state and write a stateless composable
  • Keep screen state in a ViewModel with StateFlow and read it in Compose
  • Describe unidirectional data flow and the MVVM layers

Leyla has reached the third question in a quiz app and turns her phone sideways — the score resets and she has to start again. All bugs like this have the same root: the state was not kept in the right place. State is any value that can change over time and affects the screen: typed text, a switch position, a loaded list, a score. In this lesson you will learn where and how to keep it.

remember and mutableStateOf

Compose only notices changes to observable state. mutableStateOf(...) creates such a value: when you change it, Compose calls the functions that read it again (recomposition). remember { } keeps the value between recompositions; without it the function would start from scratch on every call. The by keyword lets you work with it without writing .value.

Wrong: a plain variable
@Composable
fun Counter() {
    var count = 0
    Button(onClick = { count++ }) {
        Text("Clicked $count times")
    }
}
Right: remembered state
@Composable
fun Counter() {
    var count by rememberSaveable { mutableIntStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Clicked $count times")
    }
}
On the left count grows, but Compose never learns about it and the button always shows 0. On the right every tap updates the screen, and the value even survives a screen rotation.

State hoisting

Definition
State hoisting

Moving state out of a composable into the function that calls it. The composable receives the value as a parameter and reports change requests upward through a lambda. Such a function becomes stateless: it is easy to test, preview and reuse.

Kotlin
@Composable
fun CounterScreen() {
    var count by rememberSaveable { mutableIntStateOf(0) }
    Counter(count = count, onIncrement = { count++ })
}

@Composable
fun Counter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
    Button(onClick = onIncrement, modifier = modifier) {
        Text("Clicked $count times")
    }
}
CounterScreen owns the state, while Counter only shows it and reports taps.

ViewModel and StateFlow

A ViewModel is a class that holds a screen's data and logic. It is not recreated when the screen rotates, so the score is not lost. We expose the state through a StateFlow — a flow that always has a current value and broadcasts changes. Inside, a changeable MutableStateFlow is kept private, and only a read-only StateFlow is exposed.

Kotlin
data class QuizUiState(
    val question: Int = 1,
    val score: Int = 0,
    val finished: Boolean = false
)

class QuizViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(QuizUiState())
    val uiState: StateFlow<QuizUiState> = _uiState.asStateFlow()

    fun answer(correct: Boolean) {
        _uiState.update { state ->
            val next = state.question + 1
            state.copy(
                question = next,
                score = if (correct) state.score + 1 else state.score,
                finished = next > 5
            )
        }
    }
}
The whole screen state lives in one immutable data class; update atomically replaces it with a new copy.
Kotlin
@Composable
fun QuizScreen(viewModel: QuizViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    Column(modifier = Modifier.padding(16.dp)) {
        if (state.finished) {
            Text("Your score: ${state.score} / 5")
        } else {
            Text("Question ${state.question} of 5")
            Button(onClick = { viewModel.answer(correct = true) }) { Text("Correct") }
            Button(onClick = { viewModel.answer(correct = false) }) { Text("Wrong") }
        }
    }
}
On screen: “Question 1 of 5” and two buttons; after the fifth answer, a result such as “Your score: 3 / 5”. Rotating the screen does not affect it.

The viewModel() function creates the ViewModel for the screen or returns the existing one. collectAsStateWithLifecycle() turns the flow into Compose state and stops collecting while the app is in the background. These functions come from the lifecycle-viewmodel-compose and lifecycle-runtime-compose libraries.

  1. 1
    Add them to the catalog

    In gradle/libs.versions.toml, add two lines to the [libraries] section:
    androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
    androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }

  2. 2
    Add them to the module

    In app/build.gradle.kts, add implementation(libs.androidx.lifecycle.viewmodel.compose) and implementation(libs.androidx.lifecycle.runtime.compose) to the dependencies block.

  3. 3
    Sync and import

    Press Sync Now, then put the cursor on the red viewModel and collectAsStateWithLifecycle, press Alt+Enter and choose import.

Unidirectional data flow and MVVM

In the code above, data moves in a circle: the screen shows the state → the user taps a button → the screen sends an event to the ViewModel (answer) → the ViewModel creates a new state → the screen shows it. This is called unidirectional data flow (UDF). Because only one place changes the state, bugs become easier to find. You can check this logic with plain Kotlin too:

Kotlin
data class CounterState(val count: Int = 0, val message: String = "")

sealed interface CounterEvent {
    data object Increment : CounterEvent
    data object Reset : CounterEvent
}

fun reduce(state: CounterState, event: CounterEvent): CounterState = when (event) {
    CounterEvent.Increment -> state.copy(
        count = state.count + 1,
        message = if (state.count + 1 >= 3) "Great streak!" else ""
    )
    CounterEvent.Reset -> CounterState()
}

fun main() {
    var state = CounterState()
    val events = listOf(CounterEvent.Increment, CounterEvent.Increment, CounterEvent.Increment, CounterEvent.Reset)
    for (event in events) {
        state = reduce(state, event)
        println("$event -> $state")
    }
}
Expected output
Increment -> CounterState(count=1, message=)
Increment -> CounterState(count=2, message=)
Increment -> CounterState(count=3, message=Great streak!)
Reset -> CounterState(count=0, message=)
MVVM layerJobExample
Model (data)gets data from the internet or a database and stores itLessonRepository
ViewModelholds the screen state and handles eventsQuizViewModel
Viewshows the state and passes events to the ViewModelQuizScreen
A ViewModel should never hold composables or a Context directly.
move the selected code into its own function — Extract Function (Mac: Cmd+Option+M)Ctrl+Alt+M
rename across the whole project — RenameShift+F6
find where a function is called — Find Usages (Mac: Option+F7)Alt+F7
file structure: the list of classes and functions (Mac: Cmd+F12)Ctrl+F12

Key points

  • Compose only sees changes to observable state such as mutableStateOf; remember keeps it between recompositions.
  • rememberSaveable keeps simple values even through a rotation; the screen's main data lives in a ViewModel.
  • Hoist state: the value goes down, events such as onValueChange go up.
  • A ViewModel keeps state in a private MutableStateFlow and exposes a public StateFlow; the screen reads it with collectAsStateWithLifecycle().
  • MVVM: the Model provides data, the ViewModel manages state, the View shows it — data moves in one direction.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
What happens to a value kept with remember { mutableStateOf(0) } when the screen rotates?