DEV Community

Charles Hartmann
Charles Hartmann

Posted on

remember vs rememberSaveable vs ViewModel: which state survives what

Almost every "my screen does not update" or "my text field resets" bug in Jetpack Compose comes down to two questions. Does Compose know the value changed? And how long is the value kept?

This article answers both, with a table you can keep next to your editor.

The loop

State is any value that can change over time and that the UI shows: the text in a field, whether a dialog is open, the list of items. In Compose the UI is a function of state. You never tell a widget to change. You change the state, and Compose calls the composables that read it again.

Compose tracks reads. When a composable reads a State object while it runs, Compose records that. When the value changes, Compose schedules every composable that read it to recompose, and skips the ones that did not.

That is why the value has to live in an observable State holder. A plain Kotlin variable changes silently, and Compose never hears about it.

Two bugs, two fixes

Here is a counter that works:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Added $count times")
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things are happening, and each one fixes a different bug.

  • mutableStateOf(0) creates an observable holder. Writing count++ changes it, and Compose recomposes the Text that reads it.
  • remember { } keeps that holder across recompositions. Without it, every recomposition would run mutableStateOf(0) again and the counter would snap back to zero.

So the two bugs look like this:

  1. A plain var. You write var count = 0 inside the composable. The button changes the variable, nothing observes it, and the screen never updates.
  2. mutableStateOf without remember. You write val count = mutableStateOf(0). It updates once and then resets on the next recomposition.

You need both: an observable holder, and remember to keep it.

The by keyword is Kotlin property delegation. It lets you write count instead of count.value. It needs two imports, androidx.compose.runtime.getValue and androidx.compose.runtime.setValue, which Android Studio adds when you accept its suggestion.

How long does state live?

remember survives recomposition, but not much else. Rotate the phone, switch to dark mode or change the system language and Android recreates the Activity, and with it your whole composition. Everything held in remember is gone. Leave the app in the background long enough and the system may kill the process, which wipes more still.

This is the survival table from the book:

Holder Recomposition Rotation / dark mode Process death (in background) App closed and reopened
remember yes no no no
rememberSaveable yes yes yes no
ViewModel yes yes no no
Room / DataStore yes yes yes yes

A ViewModel plus SavedStateHandle also survives process death for small values.

rememberSaveable

rememberSaveable is remember that also writes the value into the saved instance state, so it survives configuration changes and process death. It works out of the box for anything that fits in a Bundle: numbers, strings, booleans and parcelable types. For anything else you can pass a custom Saver.

var draftName by rememberSaveable { mutableStateOf("") }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
Enter fullscreen mode Exit fullscreen mode

Scroll positions already save themselves. rememberLazyListState() and rememberScrollState() are built on rememberSaveable, so a list keeps its position through rotation without any work from you.

ViewModel

A ViewModel survives configuration changes. Rotate the phone and the Activity and every composable are recreated, but the same ViewModel instance is handed back, with its state and any running coroutines intact. It is cleared only when its owner is gone for good: the user leaves the screen or the Activity finishes.

That makes it the right home for screen state that comes from data and rules. It is the wrong home for references to anything with a shorter life. Never store a Context, a View or a composable-owned object in a ViewModel.

Room and DataStore

Anything that must survive the app being closed belongs in Room or DataStore. Nothing held only in memory makes it through a fresh start.

A rule of thumb

  • UI element state (is this menu expanded, the scroll position, a draft in a text field) lives in the composable, with remember or rememberSaveable.
  • Screen state that comes from data or business rules (the list of items, whether saving succeeded) lives in a ViewModel, which survives configuration changes on its own.
  • Anything that must survive the app being closed lives in Room or DataStore.

The limit of rememberSaveable

Here is a small screen that adds items and keeps them through rotation, entirely in the UI layer:

@Composable
fun PantryScreenStateOnly() {
    var names by rememberSaveable { mutableStateOf(listOf<String>()) }
    Column(Modifier.fillMaxSize()) {
        AddItemBar(onAdd = { name -> if (name.isNotBlank()) names = names + name })
        LazyColumn {
            items(names) { name -> ListItem(headlineContent = { Text(name) }) }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It works. It also shows the limit. The list is a List<String> because that saves into a Bundle without a custom saver, and it is gone the moment the app is closed. The next steps are to move the list into a ViewModel and then into a database.

AddItemBar does not change when you do that, because its state is hoisted. The stateful wrapper owns only the draft text:

// Stateful wrapper: owns the state, delegates the drawing
@Composable
fun AddItemBar(onAdd: (String) -> Unit, modifier: Modifier = Modifier) {
    var name by rememberSaveable { mutableStateOf("") }
    AddItemBar(
        name = name,
        onNameChange = { name = it },
        onAdd = { onAdd(name.trim()); name = "" },
        modifier = modifier,
    )
}
Enter fullscreen mode Exit fullscreen mode

The stateless version underneath takes name, onNameChange and onAdd as parameters. That value plus callback pair is the same shape as TextField, Checkbox and Switch in Material itself.

One more trap: mutating in place

Compose notices when a State holder gets a new value. It does not notice when you reach into the old value and change it.

// Wrong: mutates the list in place, Compose sees the same object
val items = remember { mutableStateOf(mutableListOf<PantryItem>()) }
items.value.add(newItem) // nothing recomposes

// Right: replace the value with a new immutable list
var items by remember { mutableStateOf(listOf<PantryItem>()) }
items = items + newItem
Enter fullscreen mode Exit fullscreen mode

Treat UI state as values. Data classes with val properties and copy() make this natural.

Quick checklist

  • Screen never updates? Look for a plain var or a mutated list.
  • Value resets on every recomposition? You are missing remember.
  • Value lost on rotation? Use rememberSaveable or a ViewModel.
  • Value lost after the app is closed? It belongs in Room or DataStore.

This is adapted from my book Compose Without the Confusion (Android the Right Way, Book 2). The free sample is here and the full book is here.

Drafted with AI help and edited by the author; based on his book.

Top comments (0)