Compose state, recomposition, and side effects
Write Compose UI that reacts predictably when values change, items move, or effects restart.
Jetpack Compose describes UI from state. When observable state that a composable read changes, Compose schedules recomposition of the parts of the tree that depend on it. Recomposition is normal and may run often. A composable body must remain a description of UI, not a place for network calls, analytics floods, or navigation storms.
Mid-level Compose skill is less about knowing every widget and more about state lifetime, stability, effect keys, and list identity.
Learning goals
- Choose
remember,rememberSaveable, ViewModel state, and disk for the right lifetime. - Explain recomposition and why side effects need effect APIs.
- Key
LaunchedEffect/DisposableEffectcorrectly. - Provide stable keys in Lazy lists.
- Hoist state so UIs stay testable and reusable.
Mental model: composition phases
Compose roughly:
- Composition - build/update the tree of composables.
- Layout - measure and place.
- Drawing - draw pixels.
State reads during composition subscribe that scope to changes. When state changes, affected scopes recompose.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count")
}
}
Each click writes state → recomposition → button label updates.
Choose state lifetime on purpose
| API | Survives recomposition | Survives config change | Survives process death |
|---|---|---|---|
remember | Yes | No | No |
rememberSaveable | Yes | Yes (small) | Yes (small) |
| ViewModel state | Yes | Yes | No (unless SavedStateHandle) |
| Database / DataStore | Yes | Yes | Yes |
// Ephemeral animation / dropdown
var expanded by remember { mutableStateOf(false) }
// Query field the user expects after rotation
var query by rememberSaveable { mutableStateOf("") }
// Business screen state
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Keying remember
If the remembered value depends on an input identity, pass keys:
val formatter = remember(locale) { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) }
Without locale as a key, rotation of language might keep a stale formatter.
State hoisting
Move state up so the child is reusable and testable:
@Composable
fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier,
)
}
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
val query by viewModel.query.collectAsStateWithLifecycle()
SearchField(
query = query,
onQueryChange = viewModel::onQueryChange,
)
}
Stateless composables take state in and send events out. Stateful wrappers own remember for previews and simple local chrome.
Side effects need clear APIs
Never do this inside a composable body unconditionally:
@Composable
fun Broken(userId: String) {
// Runs on every recomposition - disaster
analytics.track("profile_open")
api.preload(userId)
}
LaunchedEffect
Starts a coroutine tied to the composition; cancels and restarts when keys change:
LaunchedEffect(userId) {
analytics.track("profile_open", mapOf("id" to userId))
viewModel.load(userId)
}
Keys matter:
LaunchedEffect(Unit)- once per enter composition.LaunchedEffect(userId)- again whenuserIdchanges.- Wrong keys → missed runs or infinite loops.
DisposableEffect
For listeners that need dispose:
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) player.play()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
rememberCoroutineScope
For event-driven work (button click), not for work that should auto-start:
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { snackbarHost.showSnackbar("Saved") }
}) { Text("Save") }
derivedStateOf
When you compute from state that changes often but the derived result rarely does:
val listState = rememberLazyListState()
val showScrollUp by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
Without derivedStateOf, every scroll delta could recompose heavy parents that only care about a boolean.
Identity in lists
Lazy lists need stable identity to reuse compositions correctly:
LazyColumn {
items(
items = articles,
key = { it.id },
) { article ->
ArticleRow(article)
}
}
Missing keys cause wrong item state when the list reorders (checkbox state jumping rows, animations glitching).
For items that host their own state, keys are not optional.
Stability and recomposition performance (practical)
Compose skips recomposition of a composable when parameters are stable and equal.
Practical mid-level guidance:
- Prefer immutable data (
valproperties, immutable lists). - Avoid passing rapidly changing objects into huge parents - hoist reads closer to leaves.
- Use
Modifierchains carefully; allocate heavy objects inremember. - Prefer
key(item.id) { ... }when manually placing items outside Lazy APIs.
You do not need to memorize compiler stability inference tables on day one; you need to know why a whole screen recomposes on every keystroke (state read too high in the tree).
Navigation and effects
Navigation is a one-off effect. Trigger it from events, not sticky state:
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is Event.OpenDetail -> navController.navigate("detail/${event.id}")
}
}
}
Avoid navigating during composition based on a flag that remains true across recompositions without consumption.
Interop notes
AndroidView/AndroidViewBindingfor legacy Views.ComposeViewin Fragments withDisposeOnViewTreeLifecycleDestroyed.- Collect flows with
collectAsStateWithLifecycle, not barecollectAsState, when lifecycle matters.
Common pitfalls
- Side effects in composition without effect APIs.
LaunchedEffectmissing keys → stale captures or never re-running.- Holding ViewModel state only in
remember- lost on rotation. - Lazy lists without
key. - Reading a high-frequency state at the root of a giant screen.
- Using
rememberCoroutineScopefor load-on-enter instead ofLaunchedEffect.
How interviewers probe this
- “What is recomposition?”
- “
remembervsrememberSaveablevs ViewModel?” - “When does
LaunchedEffectrestart?” - “Why keys in LazyColumn?”
- “How do you show a Snackbar once?”
- Performance: “typing in a search box recomposes the whole feed - what do you look for?”
Practice checklist
- Build a search screen: local
querystate, debounced ViewModel search, list keys. - Force a bug with missing list keys; fix it.
- Migrate a composition-time analytics call into
LaunchedEffect. - Profile recomposition with Layout Inspector / compose metrics if available.
What you should be able to explain
- Composition as a function of state.
- State lifetime table with examples.
- Effect APIs and key semantics.
- List identity and state hoisting.
Next: Architecture and the data layer - where ViewModels, repositories, and models sit so Compose stays a renderer.
Discussion
Comments are powered by GitHub Discussions. Sign in with GitHub to join in.