Pocket Studio Academy
HomePart 44.2

ViewModel — state that outlives the screen

Full course12 min read·4 questions

Turn the phone sideways and Dice Duel forgets the score. Here is exactly why that happens, and how a ViewModel keeps your state alive while Android throws the screen away and builds a new one.

Turn your phone sideways

Open Dice Duel. Play until somebody has 12 points. Now rotate the phone.

Both scores are zero.

Nothing crashed. No error appeared. Your code is not wrong. What happened is that Android did exactly what it always does when the screen shape changes: it destroyed your entire and built a brand new one.

Every remember you wrote went with it. remember means "remember for as long as this screen exists", and the screen stopped existing.

This is called a , and rotation is only the most obvious one. Switching to dark mode does it. Changing the system font size does it. Changing the phone's language does it. Unfolding a folding phone does it twice.

Think of it like this

Imagine you are working out a long sum on a whiteboard in a meeting room.

Every so often the building manager repaints the room. They are very thorough: they wipe the board completely clean first. Nothing you can write on that board survives a repaint.

You could complain. Or you could keep the working-out in a notebook in your pocket, and use the whiteboard only to show people the current answer. Repaint the room as often as you like — the notebook is still in your pocket, and you copy the number back onto the fresh board in about a second.

The whiteboard is your screen. The notebook is a .

What a ViewModel actually is

A ViewModel is an ordinary Kotlin class that extends ViewModel. That is the entire trick. What makes it special is not the class — it is who keeps hold of it.

When you ask for a ViewModel from a screen, Android does not hand you a new one each time. It looks in a store attached to the Activity, and:

  • if one already exists, you get that same object back;
  • if not, it creates one and files it in the store.

During a rotation, Android deliberately carries that store across to the new Activity. The screen is destroyed; the store is not. So the new screen asks for its ViewModel, gets the old object, and the scores are still there.

The ViewModel is finally thrown away when the screen goes for good — when the user presses Back out of it, or the Activity finishes. At that point Android calls onCleared() on it.

The smallest possible ViewModel

Here is Dice Duel's score, moved out of the composable entirely.

GameViewModel.ktkotlin
1package com.nativeworks.diceduel
2
3import androidx.compose.runtime.getValue
4import androidx.compose.runtime.mutableStateOf
5import androidx.compose.runtime.setValue
6import androidx.lifecycle.ViewModel
7
8class GameViewModel : ViewModel() {
9
10  var p1 by mutableStateOf(0)
11    private set
12
13  var p2 by mutableStateOf(0)
14    private set
15
16  var turn by mutableStateOf(1)
17    private set
18
19  fun award(points: Int) {
20    if (turn == 1) p1 += points else p2 += points
21    turn = if (turn == 1) 2 else 1
22  }
23
24  fun newGame() {
25    p1 = 0
26    p2 = 0
27    turn = 1
28  }
29}

Using it from a screen

Add one dependency, which Pocket Notes and Focus Flow both already have:

app/build.gradle.ktskts
1implementation(
2  libs.androidx.lifecycle.viewmodel.compose
3)

Then ask for the ViewModel by giving it a default value:

GameScreen.ktkotlin
1@Composable
2fun GameScreen(
3  vm: GameViewModel = viewModel()
4) {
5  ScoreBoard(p1 = vm.p1, p2 = vm.p2, turn = vm.turn)
6
7  DieFace(
8    value = die,
9    onRoll = { vm.award(Random.nextInt(1, 7)) }
10  )
11}

Two details worth noticing.

viewModel() is a default parameter value, not a line inside the body. That is deliberate: a test or a @Preview can pass in its own ViewModel with a pretend score, because the parameter is still just a parameter.

And this is from Lesson 4.1, carried one floor higher. ScoreBoard still takes plain numbers. It has no idea a ViewModel exists — which is why it stays previewable.

Note

The state has left the composable, but the composable's shape did not change. That is the payoff for hoisting first. Composables that reach out and grab their own data are the ones that are painful to move.

Doing slow work: viewModelScope

Every ViewModel comes with a built in, called . Work launched in it is cancelled automatically when the ViewModel is cleared.

That matters more than it sounds. A started in a composable and left running after the user leaves is a leak: it holds memory, and when it finishes it writes to a screen nobody is looking at. viewModelScope makes that impossible without you thinking about it.

Pocket Notes uses it everywhere it touches the database:

NotesViewModel.ktkotlin
1fun delete(note: Note) {
2  viewModelScope.launch { repo.delete(note) }
3}

repo.delete is a function — it may pause while the disk does its work. launch starts a coroutine that is allowed to pause. And if the user closes the app mid-delete, the scope is cancelled with it.

When the ViewModel needs a Context

Room needs a to open a database. But a ViewModel outlives the Activity, so storing an Activity in one is a memory leak — you would be holding a destroyed screen alive forever.

The safe way is AndroidViewModel, which hands you the Application object. The Application lives as long as the whole process, so keeping it is harmless.

NotesViewModel.ktkotlin
1class NotesViewModel(
2  app: Application
3) : AndroidViewModel(app) {
4
5  private val repo = NotesRepository(
6    NotesDatabase.get(app).noteDao()
7  )
8}

That is the real first six lines of Pocket Notes' ViewModel. No dependency injection library, no extra dependency — which matters when your builds run on a phone.

Careful

A ViewModel survives rotation. It does not survive Android killing your app in the background to free memory — that is , and it wipes everything held in memory.

Most apps do not notice, because anything important has already been written to a database. But the difference is real, and Lesson 4.8 shows you how to see it happen on your own phone and what to do about it.

Try it in Pocket Studio

You will watch state survive a rotation for the first time.

  1. Open Pocket Studio, tap Projects, then Dice Duel.
  2. Tap Editor, then the + button, and choose New Kotlin file. Name it GameViewModel.
  3. Type the GameViewModel class from this lesson.
  4. Open app/build.gradle.kts. Under dependencies {, add the lifecycle.viewmodel.compose line shown above.
  5. Tap Sync when Pocket Studio offers it at the top of the editor.
  6. Open GameScreen.kt. Add vm: GameViewModel = viewModel() as a parameter, and let Pocket Studio add the androidx.lifecycle.viewmodel.compose.viewModel import.
  7. Replace the three remember lines for p1, p2 and turn with reads of vm.p1, vm.p2 and vm.turn.
  8. Tap Run, score a few points, then rotate the phone. The score stays.
  9. Now press Back to leave the app entirely, then reopen it. The score is gone — that is the ViewModel being cleared on purpose, because the screen finished for good.
Error Doctor5 common errors
java.lang.RuntimeException: Cannot create an instance of class com.nativeworks.pocketnotes.ui.NotesViewModel
MeansYour ViewModel's constructor takes arguments Android does not know how to supply. By default it can only build a ViewModel with an empty constructor, or an AndroidViewModel that takes an Application.
FixEither extend AndroidViewModel(app) and build what you need from the Application, or supply a . The stack trace usually names the missing constructor right underneath, as NoSuchMethodException: <init> [].
java.lang.IllegalStateException: CompositionLocal LocalViewModelStoreOwner not present
MeansYou called viewModel() somewhere with no owner to store it in — most often inside a @Preview, or inside a plain Dialog that sits outside the Activity's composition.
FixIn a preview, pass a ViewModel in by hand instead of calling viewModel(). That is exactly why the ViewModel is a default parameter rather than a line in the body.
e: Unresolved reference: viewModel
MeansThe lifecycle-viewmodel-compose library is not on the project's dependency list, so the viewModel() function does not exist yet.
FixAdd implementation(libs.androidx.lifecycle.viewmodel.compose) to app/build.gradle.kts, then tap Sync. Adding the import alone will not help — the code is not downloaded yet.
java.lang.IllegalStateException: Your activity is not yet attached to the Application instance. You can't request ViewModel before onCreate call.
MeansA ViewModel was requested too early in the Activity's life, before Android had connected it to the Application.
FixAsk for it from inside setContent { ... }, or from a composable, never from a field initialiser on the Activity itself.
The score still resets when I rotate, even though I added a ViewModel
MeansAlmost always one of two things: the screen is still reading its old remember values, or you created the ViewModel with remember { GameViewModel() } instead of viewModel().
Fixremember ties the object to the composition, which rotation destroys. It must be viewModel() — that is the call that looks in the store Android carries across.
Recap
  • Rotation, dark mode and font-size changes are : Android destroys the Activity and builds a new one. Everything in remember is lost.
  • A is kept in a store that Android carries across that rebuild, so the same object comes back.
  • Ask for it with viewModel() — as a default parameter, so previews and tests can supply their own.
  • Expose state with private set so the screen can read it but only the ViewModel can change it.
  • Launch slow work in ; it is cancelled for you.
  • Use AndroidViewModel when you need a . Never store an Activity.
  • Next: mutableStateOf in a ViewModel works, but it ties your logic to Compose and lets five values drift apart. and one UI state object fix both problems.