Skip to content
Commitwise
Menu
Android Development

State and Recomposition

Make Jetpack Compose screens update when they should and keep their state when they should, with remember, rememberSaveable, state hoisting and ViewModels.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A like button that won’t stay liked. A list that ignores the item you just added. A search field that empties itself when you rotate the phone. In Jetpack Compose, all three are the same kind of bug: state that Compose isn’t watching, or state that doesn’t survive as long as you think.

New to some of these? Open what you need, or skip ahead.

Basics:What is a composable function?

In Jetpack Compose, UI is written as functions marked @Composable:

@Composable
fun Greeting(name: String) {
    Text("Hello, $name!")
}

A composable describes what the screen should show for the data it receives. It doesn’t return a view; it emits UI elements. When the data changes, Compose calls the function again (recomposition) and updates only what changed. Composables are combined by calling each other inside layouts like Column and Row.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:What are Activities and configuration changes?

On Android, an Activity is an app screen (or, in Compose apps, often the single container for all screens). Android creates and destroys Activities as the user moves around.

A configuration change, like rotating the phone or switching to dark mode, destroys the Activity and creates a new one. Anything stored only in the Activity (or in plain remember in Compose) is lost. Android also kills apps in the background to save memory (process death), and restores them when the user returns. Where you keep state decides what survives each of these.

Basics:Mutable or immutable?

A mutable object can be changed after it’s created (list.add(x), user.name = "Ana"). An immutable one can’t: to “change” it, you create a new one with the new value.

Immutable data is easier to reason about: nobody can change it behind your back, and it’s safe to share between parts of the program (and between threads). Kotlin’s val properties and List, Java’s record and List.of, and Swift’s let and structs are the usual tools.

Part Not started

Composables redraw from state

Think of a stadium scoreboard. Nobody repaints the numbers when a team scores: someone updates the score, and the board shows it.

In Compose, your UI is a function. You don’t change a TextView directly. You change state, and Compose calls your composable function again to draw the new version. That re-run is called recompositionRecompositionJetpack Compose running a composable function again because state it reads has changed.Open in glossary →. For it to happen, Compose has to know your value changed, which is why state lives in special holders like mutableStateOf.

Compose snapshots track reads of State objects during composition. When a State that a composable read is written, that composable’s restart scope is invalidated and re-executed on the next frame; composables whose parameters are unchanged (and stable, or equal under strong skipping) are skipped. Remembered values live in the slot table, tied to the composable’s position in the composition, and are forgotten when it leaves.

So: writes to non-snapshot objects aren’t seen, remember lasts as long as the call site stays in the composition, and configuration changes destroy the composition unless you save state elsewhere.

A counter, broken twice

The best way to see what remember and mutableStateOf do is to leave them out. Here’s the counter most people write first:

@Composable
fun Counter() {
    var count = 0

    Button(onClick = { count++ }) {
        Text("Tapped $count times")
    }
}

Tap it ten times and the button still says Tapped 0 times. The tap really does change count: it’s 10 now. But count is an ordinary Kotlin variable, and Compose has no idea it changed, so nobody redraws the button. It’s like updating the score in your notebook while the scoreboard stays dark.

And if anything else makes Compose redraw this button (the parent screen changes, the user switches to dark mode), Counter() runs again from the first line, and var count = 0 starts over.

So the value needs to be one Compose watches:

var count by mutableIntStateOf(0)

Now a tap does make Compose redraw. Here’s what happens, step by step:

  1. The tap sets count to 1. Compose notices: “Counter read this value, so Counter must be drawn again.”
  2. To redraw, Compose calls Counter() again, from the top.
  3. The first line runs again: mutableIntStateOf(0) creates a brand new state that holds 0.
  4. The button shows Tapped 0 times. The 1 was in the old state, which nobody points to anymore.

Compose did its job perfectly; the function threw the value away. (Android Studio even marks this line as an error: “Creating a state object during composition without using remember”.)

The fix is to keep the state object between runs of the function. That’s exactly what remember does:

var count by remember { mutableIntStateOf(0) }

The first time Counter() runs, remember runs the code in the braces and stores the result, tied to this spot on the screen. Every later run, it skips the braces and hands back the stored state. So the tap sets count to 1, Compose redraws, remember returns the same state, and the button finally shows 1.

Each half fixes one problem:

Compose redraws on a tap?Value survives the redraw?After 3 taps
var count = 0NoNoTapped 0 times
mutableIntStateOf(0)YesNoTapped 0 times
remember { mutableIntStateOf(0) }YesYesTapped 3 times

Try all three. “Something else redraws” simulates a redraw the counter didn’t cause:

Three counters, one difference each

A plain variable

var count = 0

count in memory: 0

  1. Tap the button.

State, not remembered

var count by mutableIntStateOf(0)

count in memory: 0

  1. Tap the button.

Remembered state

var count by remember { mutableIntStateOf(0) }

count in memory: 0

  1. Tap the button.
Dev note
The by delegate needs import androidx.compose.runtime.getValue and setValue; Android Studio adds them when you accept its suggestion.

The three attempts fail for different reasons. A plain local is invisible to the snapshot system, so no write invalidates anything. An unremembered mutableIntStateOf is a real snapshot object, so the write invalidates Counter, but recomposition re-executes the initializer and the new object replaces the old one: the state lives exactly one composition. remember stores the object in the slot table at this call site, so it survives every recomposition until the call site leaves the composition (an if turns false, a list item scrolls away, the activity is recreated).

Three counters, one difference each

A plain variable

var count = 0

count in memory: 0

  1. Tap the button.

State, not remembered

var count by mutableIntStateOf(0)

count in memory: 0

  1. Tap the button.

Remembered state

var count by remember { mutableIntStateOf(0) }

count in memory: 0

  1. Tap the button.
Spot the bugA like button that won't stay liked

Tap the line with the bug.

@Composable
fun LikeButton() {
    var liked by mutableStateOf(false)

    IconButton(onClick = { liked = !liked }) {
        Icon(
            imageVector = if (liked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
            contentDescription = if (liked) "Unlike" else "Like",
        )
    }
}

Recomposition, skipping and stability

Take a search screen: a text field on top, 500 products below. The user types “s”, “h”, “o”, “e”, “s”.

data class SearchUiState(val query: String = "", val products: List<Product> = emptyList())

@Composable
fun SearchScreen(state: SearchUiState, onQueryChange: (String) -> Unit) {
    SearchField(state.query, onQueryChange)   // must redraw: query changed
    ProductList(state.products)               // does it?
}

Every keystroke produces a new state, so SearchScreen recomposes and calls ProductList again. Compose then decides whether ProductList can be skipped: if its parameters are “the same as last time”, it reuses what it drew before. What “the same” means is the whole story.

  • Stable types are compared with equals. A String, an Int, or a data class with only vals of stable types is stable: Compose can trust that equal means unchanged.
  • Unstable types are everything Compose can’t prove won’t change behind its back: List<T> (it’s an interface, and the object behind it might be a MutableList), classes with var properties, and classes from modules compiled without the Compose compiler.

Before Kotlin 2.0.20, an unstable parameter made a composable never skippable. ProductList(products: List<Product>) ran on all five keystrokes, even though the products didn’t change. Teams wrapped lists in @Immutable classes or used kotlinx.collections.immutable just to make skipping work.

With strong skipping (on by default with the Compose compiler since Kotlin 2.0.20), unstable parameters are compared by instance (===). state.copy(query = "sh") keeps the same products list object, so ProductList is skipped four times out of five. Lambdas are also remembered automatically, so onClick = { vm.open(product.id) } no longer counts as a new parameter every time.

The trap that’s left is creating new instances with the same contents:

// In the ViewModel: maps all products again for every query
val state = query.map { q -> SearchUiState(q, repo.products.map { it.toUi() }) }

map { it.toUi() } builds a new list on every emission. It’s equals to the old one, but not ===, so ProductList recomposes on every keystroke again. Map the products once (or only when they change), and let the query change only the query.

How to check instead of guessing: in Android Studio’s Layout Inspector, turn on recomposition counts, type “shoes”, and look at ProductList: 0 means it was skipped, 5 means it wasn’t. The Compose compiler can also write reports (composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler") }) that list every composable as skippable or not, and every parameter as stable or unstable.

Reading state as late as possible

Compose draws a frame in three phases: composition (run composables, decide what to show), layout (measure and place) and draw. A state read triggers the phase it happens in, and everything after it. Recomposition is the most expensive, so state that changes every frame should be read as late as possible.

A collapsing header that moves at half the scroll speed:

// Reads scrollState.value during composition:
// 60 recompositions per second while the user scrolls.
Header(modifier = Modifier.offset(y = (-scrollState.value / 2).dp))

// Reads it inside the offset lambda, during layout:
// no recomposition at all, only the header is moved.
Header(modifier = Modifier.offset { IntOffset(0, -scrollState.value / 2) })

The same idea in another shape: a “Scroll to top” button that shows once the user has scrolled.

// Recomposes every time the first visible index changes: 1, 2, 3, … 40
val showButton = listState.firstVisibleItemIndex > 0

// Recomposes only when the answer flips between false and true
val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }

derivedStateOf is worth it exactly when the result changes much less often than its input. For fullName = "$first $last", which changes whenever its inputs do, it only adds overhead.

When does a state write recompose?

Predict the output

The user taps the button once. What new lines does the log show?

@Composable
fun DoubleCounter() {
    var count by remember { mutableIntStateOf(0) }

    SideEffect { Log.d("Counter", "count = $count") }

    Button(onClick = {
        count++
        count++
    }) {
        Text("Add two")
    }
}
  • Compose records which State objects a composable reads; a write invalidates those readers, and they re-run once before the next frame. Two writes in one click handler give one recomposition that sees the final value.
  • remember stores a value in the slot table at the call site’s position. mutableStateOf without remember creates a fresh state on every recomposition (lint warns about it).
  • Skipping: with strong skipping (default since Kotlin 2.0.20), stable parameters are compared with equals, unstable ones (like List<T>) by instance. A new list on every emission recomposes even with equal contents.
  • Read late: Modifier.offset { IntOffset(…) } reads scroll state in the layout phase, not composition; derivedStateOf turns a fast-changing input into a rarely changing value.
Refresher:What recomposition is

Composables are functions that describe the UI. When state they read changes, Compose calls them again with the new values and updates only what changed on screen. That re-run is recomposition.

Check this part

  1. What does remember do?

  2. var liked by mutableStateOf(false) is written without remember. The user taps and liked becomes true. What happens?

  3. With strong skipping, a composable takes an unstable List<Product>. When can it skip?

  4. Which modifier avoids recomposing the composable on every scroll frame?

Compose redraws a composable when state it read changes. mutableStateOf makes a value watched, and remember keeps it between redraws.

Part Not started

rememberSaveable: surviving rotation

Type “cats” into a search field whose text uses remember, then rotate the phone. The field is empty again.

  1. Rotating the phone (or switching to dark mode, or changing the system language) recreates the activity: Android throws the whole screen away and builds a new one for the new size.
  2. The new screen starts a new composition. remember keeps values only inside one composition, so its stored state went away with the old screen.
  3. remember { mutableStateOf("") } runs for the first time again and starts at "".

To the user, the app just deleted what they typed because they turned the phone.

A configuration change destroys the activity and with it the composition and its slot table, so everything held by remember is gone; the same happens when the system kills the process in the background.

rememberSaveable fixes it: it also writes the value into the saved instance state, a small bundle Android keeps while it recreates the activity (and even after it kills your app in the background), and reads it back:

@Composable
fun SearchScreen() {
    var query by rememberSaveable { mutableStateOf("") }

    Column {
        SearchField(query = query, onQueryChange = { query = it })
        Text("Searching for: $query")
    }
}

@Composable
fun SearchField(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange, label = { Text("Search") })
}

What fits in saved state

The saved instance state is a Bundle: primitives, strings, Parcelables, and lists of those. Two ways to get it wrong:

// 1. Product isn't Parcelable: crashes with IllegalArgumentException
//    ("cannot be saved using the current SaveableStateRegistry")
var selected by rememberSaveable { mutableStateOf<Product?>(null) }

// 2. 2,000 Parcelable products: works in testing, then crashes with
//    TransactionTooLargeException when the user switches apps
var products by rememberSaveable { mutableStateOf(loadedProducts) }

The second one is nasty because the bundle is sent to the system when the app goes to the background, and the limit (about 1 MB, shared with everything else in flight) is only hit with real data. Save what you need to find the data again, not the data itself:

var selectedId by rememberSaveable { mutableStateOf<String?>(null) }

For a small type of your own, a Saver describes how to turn it into bundle-friendly values and back (listSaver and mapSaver cover most cases).

Practice: which value survives?

Time to check that remember and rememberSaveable really feel different to you:

Predict the output

The user types cats into both fields, then rotates the phone. What do the fields show?

@Composable
fun TwoFields() {
    var draft by remember { mutableStateOf("") }
    var search by rememberSaveable { mutableStateOf("") }

    Column {
        TextField(value = draft, onValueChange = { draft = it }, label = { Text("Draft") })
        TextField(value = search, onValueChange = { search = it }, label = { Text("Search") })
    }
}
ToolSurvives recompositionSurvives rotationSurvives process death
mutableStateOf without rememberNoNoNo
remember { mutableStateOf() }YesNoNo
rememberSaveable { … }YesYesYes (Bundle-sized)
ViewModel + StateFlowYesYesNo (use SavedStateHandle)

rememberSaveable writes to the Bundle: primitives, Parcelables or a custom Saver. Large objects risk TransactionTooLargeException, so save IDs, not data.

Check this part

  1. The user types in a search field, then rotates the phone, and the text is gone. What fixes it?

  2. Which of these also survives the system killing your app in the background?

  3. You want a screen to restore a list of 2,000 loaded products after rotation. What do you put in rememberSaveable?

remember forgets on rotation; rememberSaveable stores the value in saved instance state, so it survives rotation and process death.

Part Not started

State hoisting

What if SearchField kept the text itself? It looks tidier:

@Composable
fun SearchField() {
    var query by rememberSaveable { mutableStateOf("") }
    TextField(value = query, onValueChange = { query = it }, label = { Text("Search") })
}

The field works, but the text is now locked inside it. The screen can’t show “Searching for: cats”, can’t clear the field after a search, and can’t start the search when the text changes. The usual workaround, keeping a second copy of the text in the screen, gives you two values that slowly drift apart.

That’s why SearchField in the rotation example has no state of its own. It receives the current query and calls onQueryChange when the user types. The state is hoisted (moved up) to the caller.

This is called state hoistingState hoistingMoving state up to the caller, so a component receives its value as a parameter and reports changes through a callback.Open in glossary →, and it’s the most important habit in Compose:

  • One owner. SearchScreen owns the text, so the field and the “Searching for” label can never disagree.
  • Reusable. SearchField works anywhere, with any state.
  • Easy to preview and test. Pass in any value and check what’s drawn.

The rule of thumb: values go down as parameters, events go up as lambdas.

Exerciseeasy

Build a tip calculator

Build a screen where the user types the bill amount, picks a tip of 10%, 15% or 20%, and sees the tip and the total update as they type. Split it into:

  • TipCalculatorScreen, which owns the state
  • TipCalculatorContent, a stateless composable that gets values and lambdas, so you can preview it with any numbers

Before you write code, decide: which values are state, and which are computed?

›

Only two things change because the user did something: the bill text and the chosen percentage. Keep the bill as a String, because that’s what a TextField edits, and convert it with toDoubleOrNull() ?: 0.0 when you calculate.

›

The tip and the total are plain vals computed inside TipCalculatorScreen. They’re recalculated on every recomposition, which happens exactly when the bill or the percentage changes.

SolutionTry it yourself first
@Composable
fun TipCalculatorScreen() {
    var billText by rememberSaveable { mutableStateOf("") }
    var tipPercent by rememberSaveable { mutableIntStateOf(15) }

    val bill = billText.toDoubleOrNull() ?: 0.0
    val tip = bill * tipPercent / 100

    TipCalculatorContent(
        billText = billText,
        onBillChange = { billText = it },
        tipPercent = tipPercent,
        onTipChange = { tipPercent = it },
        tip = tip,
        total = bill + tip,
    )
}

@Composable
fun TipCalculatorContent(
    billText: String,
    onBillChange: (String) -> Unit,
    tipPercent: Int,
    onTipChange: (Int) -> Unit,
    tip: Double,
    total: Double,
) {
    Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        TextField(
            value = billText,
            onValueChange = onBillChange,
            label = { Text("Bill") },
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
        )
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            listOf(10, 15, 20).forEach { percent ->
                FilterChip(
                    selected = percent == tipPercent,
                    onClick = { onTipChange(percent) },
                    label = { Text("$percent%") },
                )
            }
        }
        Text("Tip: %.2f €".format(tip))
        Text("Total: %.2f €".format(total))
    }
}

@Preview
@Composable
fun TipCalculatorPreview() {
    TipCalculatorContent("40", {}, 15, {}, tip = 6.0, total = 46.0)
}

Two pieces of state, two computed values, and a content composable with no state at all: values go down, events go up. Because TipCalculatorContent doesn’t own anything, the preview can show any situation without typing.

Want more? Add a “Split between” row for 1 to 10 people and show the amount per person. It’s one more piece of state in the screen and one more parameter in the content.

Hoist to the lowest common owner: the closest ancestor that reads the state or needs to change it. Two siblings that share a value can’t each own it; their parent does. Stateless “content” composables (SearchField(query, onQueryChange)) are what you preview, screenshot-test and reuse; the stateful wrapper around them is thin.

  • Values go down as parameters, events go up as lambdas: SearchField(query: String, onQueryChange: (String) -> Unit) owns nothing.
  • Hoist to the lowest common owner of everything that reads or writes the state; siblings that share a value get it from their parent.
  • Split each screen into a thin stateful wrapper and a stateless …Content composable you can preview with any data.

Check this part

  1. What is state hoisting?

  2. SearchField(query, onQueryChange) has no state of its own. Who owns the text?

  3. A filter bar and a results list (siblings) both need the current filter. Where does the state live?

Move state up to the caller: values go down as parameters, events go up as lambdas, and one owner keeps everything consistent.

Part Not started

Lists in state

Now a to-do list. You already know to remember things, so this looks right:

@Composable
fun TodoList() {
    val todos = remember { mutableListOf("Buy milk") }

    Column {
        todos.forEach { Text(it) }
        Button(onClick = { todos.add("Call mom") }) { Text("Add") }
    }
}

Tap Add: nothing. Tap it five more times: still only “Buy milk”. Then tick a checkbox somewhere else on the screen (anything that’s in state and makes TodoList redraw), and suddenly six “Call mom”s appear at once. The items were added every time; Compose just wasn’t told.

mutableStateOf notices when you assign a new value. Calling add on a plain MutableList changes the list’s contents, but nothing is assigned and the list isn’t a state object, so Compose never finds out. remember only keeps the list; it doesn’t make it watched. There are two correct ways:

// 1. A state list: Compose watches every add, remove and set
val todos = remember { mutableStateListOf("Buy milk") }
Button(onClick = { todos.add("Call mom") }) { Text("Add") }

// 2. A read-only list in state: replace the whole list on every change
var todos by remember { mutableStateOf(listOf("Buy milk")) }
Button(onClick = { todos = todos + "Call mom" }) { Text("Add") }

Both work, because in both cases Compose is told about the change:

  • mutableStateListOf creates a list that reports its own changes, like mutableStateOf does for a single value. It’s handy for a list that lives inside one composable.
  • A read-only List in mutableStateOf never changes; todos + "Call mom" creates a new list, and assigning it is a change Compose sees. This is the style used with ViewModels and StateFlow, as in the cart example below.

What doesn’t work is the mix: a MutableList (from mutableListOf) changed in place. The same applies to maps (mutableStateMapOf) and to changing a property of a plain object that’s stored in state: if nothing is assigned and the collection isn’t a state collection, Compose can’t see the change.

Compose observes snapshot state objects: MutableState notifies on writes (compared with structural equality by default), and SnapshotStateList/SnapshotStateMap notify on every mutation. A plain MutableList is invisible to the snapshot system, even inside remember. Prefer immutable lists replaced on change for state that crosses layers (it composes with StateFlow, copy() and skipping), and state lists for local, frequently edited UI collections.

MutableState notifies on writes (structural equality by default); SnapshotStateList / SnapshotStateMap notify on every mutation. A plain MutableList is invisible to the snapshot system, even inside remember. Use immutable lists replaced on change for state that crosses layers (works with StateFlow, copy() and skipping), and state lists for local, frequently edited UI collections.

Check this part

  1. val todos = remember { mutableListOf("Buy milk") }. A button calls todos.add("Call mom"). What does the screen show?

  2. Which of these updates the screen?

  3. Which one does the snapshot system NOT observe?

Compose only sees a list change if it's a state list (mutableStateListOf) or a new read-only list assigned to state. A plain MutableList changed in place is invisible.

Part Not started

Screen state in a ViewModel

Where should a shopping cart live? rememberSaveable in CartScreen seems enough, until you try it:

  • It doesn’t fit. Saved instance state is a small bundle meant for a query or a selected tab. A list of products would have to be Parcelable, and a big one crashes with TransactionTooLargeException.
  • It’s tied to the screen. Navigate to a product’s details and back, and a cart that lived in the screen’s composition can be gone.
  • The logic ends up in the UI. Adding, removing and the total all live inside a composable, where you can’t unit test them.

For anything that comes from data (a cart, a profile, search results), keep the state in a ViewModel. It lives as long as the screen’s place in navigation (surviving rotation too), and keeps logic out of your composables.

data class CartUiState(val items: List<Product> = emptyList()) {
    val total: Double get() = items.sumOf { it.price }
}

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

    fun add(product: Product) {
        _state.update { it.copy(items = it.items + product) }
    }
}

@Composable
fun CartScreen(viewModel: CartViewModel = viewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    CartContent(state = state, onAdd = viewModel::add)
}

CartContent is a hoisted, stateless composable again: it gets the state and an onAdd event. The ViewModel is the single owner of the cart, and collectAsStateWithLifecycle() turns its StateFlow into Compose state that stops collecting while the app is in the background.

Which one do I use?

SituationUse
UI-only state that can reset on rotation (an expanded card)remember { mutableStateOf(…) }
UI state the user would be annoyed to lose (typed text, selected tab)rememberSaveable { mutableStateOf(…) }
A list Compose should watchmutableStateListOf() or an immutable list in state
Screen data and logicViewModel + StateFlow, collected with collectAsStateWithLifecycle()

Practice: a cart badge that’s stuck

Moving state into a ViewModel doesn’t make every change visible. Compose and StateFlow both notice when the state is replaced, not when something inside it is changed:

Spot the bugCart badge stuck at 0

Tap the line with the bug.

data class CartUiState(val items: MutableList<Product> = mutableListOf())

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

    fun add(product: Product) {
        _state.value.items.add(product)
    }
}

The UI state is an immutable data class, replaced with _state.update { it.copy(…) }: atomic under concurrent updates, and a new instance is exactly what StateFlow (which compares with equals) and Compose need to see a change.

Process death and effects

The user is halfway through a long review, switches to the camera to take a photo, and the system kills your app in the background to free memory. When they come back, Android recreates the screen and a new ViewModel. A draft held in a MutableStateFlow is gone.

SavedStateHandle is the ViewModel’s own piece of saved instance state:

class ReviewViewModel(private val saved: SavedStateHandle) : ViewModel() {
    val draft: StateFlow<String> = saved.getStateFlow("draft", "")

    fun onDraftChange(text: String) {
        saved["draft"] = text
    }
}

Test it for real: put the app in the background, run adb shell am kill <your.package>, and reopen it from the recent apps. (Rotation doesn’t test this: it keeps the ViewModel.) The same size rules apply as for rememberSaveable: a draft or an ID, not a list of results.

Side effects have their own trap. A composable’s body runs on every recomposition, so this reloads the profile every time anything on the screen changes, including every keystroke in a text field:

@Composable
fun ProfileScreen(userId: String, vm: ProfileViewModel) {
    vm.load(userId)                      // runs on every recomposition
    // …
}

Pick the effect handler that matches the lifetime you want:

  • LaunchedEffect(userId) { vm.load(userId) } runs when the composable appears and again only when userId changes, and cancels the coroutine when it leaves. (Loading in the ViewModel’s init is often simpler still.)
  • DisposableEffect(lifecycleOwner) { … onDispose { … } } for things you must undo: register a listener or observer, and unregister it in onDispose.
  • rememberCoroutineScope() for work started by an event: onClick = { scope.launch { snackbarHostState.showSnackbar("Saved") } }.

Effects that outlive their inputs

Spot the bugA timer that calls the wrong callback

Tap the line with the bug.

@Composable
fun SessionTimeout(seconds: Int, onTimeout: () -> Unit) {
    LaunchedEffect(Unit) {
        delay(seconds * 1000L)
        onTimeout()
    }
}

Exercisemedium

A paged list screen

Build the state layer for a feed that loads 20 posts per page from repo.posts(after: cursor) and loads the next page as the user nears the end. It must:

  • show a full-screen spinner and a full-screen error only for the first page,
  • show a footer spinner, or a footer “Retry”, for later pages,
  • never load the same page twice, even when the list scrolls fast,
  • survive rotation without reloading.

Write the ViewModel, the UI state, and the code in the composable that triggers the next page.

›

A sealed UiState for the screen (Loading, Error, Content) plus a separate status for the next page inside Content. One boolean can’t express “we have 80 posts and page 5 failed”.

›

Observe “is the last visible item near the end?” with snapshotFlow over LazyListState.layoutInfo, use distinctUntilChanged, and only then call the ViewModel. The ViewModel keeps the real guard (one Job at a time), so the UI can’t cause duplicate loads.

SolutionTry it yourself first
sealed interface FeedUiState {
    data object Loading : FeedUiState
    data class Error(val message: String) : FeedUiState
    data class Content(val posts: List<Post>, val nextPage: NextPage) : FeedUiState
}

enum class NextPage { Idle, Loading, Failed, End }

class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
    private val _state = MutableStateFlow<FeedUiState>(FeedUiState.Loading)
    val state: StateFlow<FeedUiState> = _state.asStateFlow()

    private var cursor: String? = null
    private var pageJob: Job? = null

    init { loadFirstPage() }                      // survives rotation: runs once per ViewModel

    fun loadFirstPage() {
        pageJob?.cancel()
        _state.value = FeedUiState.Loading
        pageJob = viewModelScope.launch {
            runCatching { repo.posts(after = null) }
                .onSuccess { page ->
                    cursor = page.nextCursor
                    _state.value = FeedUiState.Content(page.posts, if (page.nextCursor == null) NextPage.End else NextPage.Idle)
                }
                .onFailure { e -> if (e is CancellationException) throw e else _state.value = FeedUiState.Error(e.message ?: "Error") }
        }
    }

    fun loadNextPage() {
        val content = _state.value as? FeedUiState.Content ?: return
        val after = cursor ?: return
        if (pageJob?.isActive == true || content.nextPage == NextPage.End) return

        _state.value = content.copy(nextPage = NextPage.Loading)
        pageJob = viewModelScope.launch {
            try {
                val page = repo.posts(after = after)
                cursor = page.nextCursor
                _state.update { s ->
                    val c = s as FeedUiState.Content
                    val known = c.posts.mapTo(HashSet()) { it.id }
                    c.copy(
                        posts = c.posts + page.posts.filterNot { it.id in known },   // pages can overlap
                        nextPage = if (page.nextCursor == null) NextPage.End else NextPage.Idle,
                    )
                }
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                _state.update { (it as FeedUiState.Content).copy(nextPage = NextPage.Failed) }
            }
        }
    }
}

@Composable
fun FeedList(content: FeedUiState.Content, onLoadMore: () -> Unit, onRetry: () -> Unit) {
    val listState = rememberLazyListState()
    val currentOnLoadMore by rememberUpdatedState(onLoadMore)

    LaunchedEffect(listState) {
        snapshotFlow {
            val info = listState.layoutInfo
            val last = info.visibleItemsInfo.lastOrNull()?.index ?: 0
            last >= info.totalItemsCount - 5
        }
            .distinctUntilChanged()
            .filter { it }
            .collect { currentOnLoadMore() }
    }

    LazyColumn(state = listState) {
        items(content.posts, key = { it.id }) { PostRow(it) }
        item(key = "footer") {
            when (content.nextPage) {
                NextPage.Loading -> CircularProgressIndicator()
                NextPage.Failed -> TextButton(onClick = onRetry) { Text("Retry") }
                NextPage.Idle, NextPage.End -> Unit
            }
        }
    }
}

Why it holds up:

  • One Job guards the next page in the ViewModel, so fast scrolling, recomposition or two triggers can’t start two requests. Retry is just loadNextPage() again, because Failed isn’t End.
  • CancellationException is rethrown: catching it as an ordinary error would turn a cancelled request into a “Failed” footer and break structured concurrency.
  • Keys in LazyColumn keep item state and scroll position stable when pages append, and deduplication protects them (duplicate keys crash a LazyColumn).
  • snapshotFlow + distinctUntilChanged turns per-frame scroll changes into one event when the user crosses the threshold, instead of a call on every frame.
  • With Paging 3 you get most of this for free (LazyPagingItems, loadState.append). Knowing how to build it is what the interview checks, and it’s what you need when Paging doesn’t fit.

Deep dives

Deep diveStrong skipping modeInterview favorite

Compose skips a composable when its parameters haven’t changed, but originally only if they were all stable types; a List parameter or a class from another module made the composable unskippable. Since the Compose compiler that ships with Kotlin 2.0.20, strong skipping is on by default.

With it, composables with unstable parameters can be skipped too: stable parameters are compared with equals, unstable ones by instance (===), and lambdas are remembered automatically. Most @Stable and @Immutable annotations added for performance become unnecessary, but passing a freshly created list or object on every recomposition still defeats skipping.

  • Immutable UI state in a StateFlow, replaced with _state.update { it.copy(…) }. Mutating a list inside the current value emits nothing.
  • A ViewModel survives configuration changes, not process death: keep small essential state (a query, a draft ID) in SavedStateHandle.
  • Effects go in LaunchedEffect(key) (restarts when the key changes), DisposableEffect or rememberCoroutineScope() for events, never in the composable body. Use rememberUpdatedState for a callback the effect should see without restarting.
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(
    private val saved: SavedStateHandle,
    private val repo: SearchRepository,
) : ViewModel() {
    val query: StateFlow<String> = saved.getStateFlow("query", "")

    val results: StateFlow<List<Item>> = query
        .debounce(300)
        .mapLatest { repo.search(it) }          // cancels the previous search
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    fun onQueryChange(q: String) {
        saved["query"] = q
    }
}

@Composable
fun SearchScreen(vm: SearchViewModel = viewModel()) {
    val query by vm.query.collectAsStateWithLifecycle()
    val results by vm.results.collectAsStateWithLifecycle()
    SearchContent(query = query, onQueryChange = vm::onQueryChange, results = results)
}
Refresher:StateFlow in one paragraph

A StateFlow always holds a current value and emits to collectors whenever a different value is set. The ViewModel exposes it read-only (asStateFlow()); the composable collects it with collectAsStateWithLifecycle(), which turns it into Compose state and pauses while the app is in the background.

Check this part

  1. Where should the data of a cart screen live?

  2. What does collectAsStateWithLifecycle() do?

  3. A ViewModel holds the user's half-written draft. Android kills the process in the background. What brings it back?

  4. LaunchedEffect(Unit) { delay(t); onTimeout() }. The parent passes a new onTimeout. Which one gets called?

Keep screen data in a ViewModel as an immutable state in a StateFlow, replace it on every change, and collect it with collectAsStateWithLifecycle.

Key takeaways

  • Compose recomposes when observable state it read changes: use mutableStateOf, mutableStateListOf or StateFlow.
  • remember keeps state across recompositions; rememberSaveable also across rotation and process death.
  • Hoist state: values down, events up, one owner.
  • Keep screen data in a ViewModel and collect it with collectAsStateWithLifecycle().
  • Compose skips a composable when its parameters didn’t change: stable types by equals, unstable ones by instance. Avoid new lists and objects on every emission.
  • Read fast-changing state as late as possible: lambda modifiers and derivedStateOf move work out of composition.
  • ViewModel survives configuration changes, not process death: keep small, essential state in SavedStateHandle.
  • Side effects go in LaunchedEffect or the ViewModel, never in the composable body.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.