Stop Recomposing the Whole Screen: State Hoisting in Jetpack Compose
A single mutableStateOf at the top of your composable tree causes full-screen recomposition on every keystroke. Hoisting state to the narrowest common ancestor narrows recomposition scope, enables stateless previews, and keeps unidirectional data flow honest.
11 Jul 2025, 22:34 UTC

The Problem: One Text Field, Full-Screen Recomposition
You add a TextField to a settings screen. Type one character and the entire column—headers, toggles, unrelated cards—recomposes. The culprit is usually a single mutableStateOf living at the top of the composable tree. Every read of that state subscribes the caller to recomposition, so a change ripples through siblings that never needed to know.
Why Hoisting Fixes It
State hoisting moves mutable state out of leaf composables and into a common ancestor—or a ViewModel—so children receive only immutable values and event lambdas. The parent owns the mutableStateOf; children become pure functions of (state, onEvent) -> Unit. This does three things at once:
- Narrows recomposition scope. Only the composable that reads the changed state re-runs.
- Enables previews and unit tests. Stateless composables render with fake data and no-op lambdas—no ViewModel mocking required.
- Decouples state lifetime. The same composable works whether state comes from a ViewModel,
rememberSaveable, or a server-driven UI payload.
Worked Example: Before and After
Below is a minimal settings row. The before version holds state locally; the after version hoists it to the parent.
// BEFORE: state lives in the leaf
@Composable
fun NotificationRow() {
var enabled by remember { mutableStateOf(true) }
Row {
Text("Push notifications")
Switch(checked = enabled, onCheckedChange = { enabled = it })
}
}
// AFTER: stateless child, hoisted parent
@Composable
fun NotificationRow(enabled: Boolean, onToggle: (Boolean) -> Unit) {
Row {
Text("Push notifications")
Switch(checked = enabled, onCheckedChange = onToggle)
}
}
@Composable
fun SettingsScreen() {
val enabled by remember { mutableStateOf(true) }
NotificationRow(enabled = enabled, onToggle = { enabled = it })
// Other rows read their own hoisted state—no cross-talk
}In the after version, toggling the switch only recomposes SettingsScreen (which reads enabled) and NotificationRow. A sibling ThemeRow stays untouched because it never reads enabled.
When to Hoist to a ViewModel
Hoist to a ViewModel when state must survive process death, configuration changes, or be shared across multiple composable entry points (e.g., a bottom sheet and a full-screen editor). Keep it in a parent composable when the state is truly UI-scoped—filter text, expanded/collapsed sections, animation progress. Over-hoisting to a single ViewModel for the whole screen defeats the purpose: every field change recomposes the entire subtree again.
Derived State: Don't Recalculate on Every Keystroke
If you compute a filtered list from a search query, wrap the calculation in derivedStateOf:
val query by remember { mutableStateOf("") }
val filtered by derivedStateOf { items.filter { it.name.contains(query) } }Without derivedStateOf, every keystroke recomposes the list composable even when the filtered result is identical. With it, the list only recomposes when filtered actually changes.
Limitations and Gotchas
rememberSaveableonly handles Parcelable or types with a customSaver. Complex object graphs need manual implementation.derivedStateOfdefers recalculation but does not prevent the composable reading it from recomposing when its direct inputs change.- Hoisting to ViewModel makes previews harder—you need a fake ViewModel or a preview-specific state holder.
mutableStateOfis not thread-safe for concurrent background writes; usesnapshotFlowor aMutexif non-UI threads mutate state.
Check It Works
- Add
Log.d("Recomp", "$name")to each composable and type in the field. Verify only the expected nodes log. - Open the Compose Preview for
NotificationRowwith staticenabled = trueandonToggle = {}. It should render instantly. - Rotate the device or kill the process. If you used
rememberSaveablefor the query string, the filter survives; if you used plainremember, it resets.
Start small: pick one screen where a single input triggers wide recomposition, hoist that state to the nearest common parent, and measure the difference. The pattern scales—each hoisted piece isolates its own subtree.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.