How it works
The explanation, with examples you can run.
State and remember
@Composablefun SearchBox(onSearch: (String) -> Unit) { var query by rememberSaveable { mutableStateOf("") } // survives rotation and process death TextField(value = query, onValueChange = { query = it }) Button(onClick = { onSearch(query) }) { Text("Search") }}mutableStateOfmakes a value observable: reading it during composition subscribes the reader.rememberstores a value in the composition so the next call gets the same one. It is forgotten when the composable leaves the composition, and on activity recreation.rememberSaveablealso writes the value to saved state (aBundle), so it comes back after recreation and process death. Keep it small: ids and text, not lists of objects.
The usual shape is state hoisting: composables take the value and an event callback, and the state lives higher up, often in a ViewModel. That makes them easy to reuse, preview and test.
The rules recomposition plays by
The Compose docs list what to expect: recomposition skips as much as it can, it is optimistic and may be cancelled, a composable may run as often as every frame of an animation, and composables may run in any order (and potentially in parallel). So the body should be fast, free of side effects, and give the same result for the same inputs.
Stability and skipping
To skip a composable whose parent recomposed, Compose compares the new parameters with the old ones. A type is stable if Compose can trust that comparison: primitives, String, function types, and classes whose public properties are vals of stable types. With strong skipping mode, the default since Kotlin 2.0.20 (the Compose compiler ships with Kotlin since 2.0):
- stable parameters are compared with
equals(); - unstable ones with
===, so passing the same instance still skips; - lambdas are remembered automatically.
List<T> is an interface and counts as unstable. Passing a new list each time (items.filter { ... } in the parent) therefore defeats skipping; compute it once in the ViewModel, or remember(items) { ... } it. @Immutable and @Stable annotations, or the compiler's stability configuration file, tell Compose to trust types it can't infer. Before reaching for them, check the compiler reports or Layout Inspector's recomposition counts.
Reading state late
A value read in composition recomposes the reader; a value read only in layout or drawing skips composition entirely:
// Recomposes every frame of the scroll:Box(Modifier.offset(y = scrollState.value.dp))// Only re-runs layout:Box(Modifier.offset { IntOffset(0, scrollState.value) })derivedStateOf helps when a value changes often but the result rarely does (for example "show the back-to-top button once the list has scrolled").
Side effects
@Composablefun ProductScreen(id: String, vm: ProductViewModel) { LaunchedEffect(id) { vm.load(id) } // runs on enter, restarts when id changes DisposableEffect(Unit) { val listener = registerShakeListener() onDispose { listener.unregister() } // runs on leave }}SideEffect runs after every successful recomposition, rememberCoroutineScope gives a scope for event handlers (onClick = { scope.launch { ... } }), and rememberUpdatedState lets a long-running effect see the latest value of a parameter without restarting.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.