State Hoisting in Jetpack Compose: Making Composables Testable and Reusable
A stateful Compose component is hard to test and reuse. Learn how to hoist state to the caller, keep composables stateless, and avoid the recomposition and prop-drilling traps.
16 Jan 2026, 18:30 UTC

The testability trap
A composable that owns its own state is easy to write and painful to test. You build a LabeledSwitch that flips itself, wire it into a settings screen, then discover you cannot force it into the “on” position from a unit test, and you cannot reuse it in a form that needs to reset every field at once. The fix is not more remember calls. It is state hoisting: moving the state up to the caller so the composable becomes a pure function of its inputs.
Hoisting state turns a component into a stateless contract—give it a value and a callback, and it renders. That contract is what makes Compose UI testable, reusable, and predictable.
What “stateless” actually means
In Compose, a stateless composable is one that derives everything it displays from its parameters. It holds no mutableStateOf and makes no decisions about what the next value should be. The same inputs always produce the same output.
This matters because Compose’s recomposition model—the process of re-running a composable function when its inputs change—works best when functions are pure. A stateful composable hides its transition logic inside the function body, so every caller inherits that hidden behavior. A stateless composable exposes the behavior as an explicit contract.
The hoisting contract: value plus callback
The standard pattern replaces internal state with two parameters:
value: T— the current state to display.onValueChange: (T) -> Unit— a lambda the composable calls when the user wants to change the state.
This is the same contract used by built-in components such as TextField and Switch. The composable reads value to render, and calls onValueChange to report an intent. It does not decide what the new value should be.
Worked example: refactoring a LabeledSwitch
Here is a typical stateful version. It works, but the parent cannot set the initial value or observe changes.
@Composable
fun LabeledSwitch(label: String, modifier: Modifier = Modifier) {
var checked by remember { mutableStateOf(false) }
Row(modifier) {
Text(label)
Switch(checked = checked, onCheckedChange = { checked = it })
}
}
Now hoist the state. The composable becomes a function of its inputs.
@Composable
fun LabeledSwitch(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Row(modifier) {
Text(label)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
A caller can now supply the state from any source. In a real screen, that source is usually a ViewModel.
@Composable
fun SettingsScreen(viewModel: SettingsViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LabeledSwitch(
label = "Enable notifications",
checked = state.notificationsEnabled,
onCheckedChange = viewModel::onNotificationsToggled
)
}
The composable is now agnostic. It works with a ViewModel, a rememberSaveable, or a test fake.
Practical check
Write a unit test that renders LabeledSwitch with checked = true and asserts the Switch displays the correct state. Then invoke onCheckedChange and verify the callback fired. No emulator required. If you can drive every state transition from a plain JVM test, the hoisting is correct.
Choosing the owner and the recomposition trade-off
Where should the hoisted state live? For simple local UI state, rememberSaveable at the screen level persists across configuration changes such as screen rotation, and across system-initiated process death by writing to the saved instance state Bundle. For state that must be shared across screens or survive longer-lived navigation, hoist to a ViewModel. A ViewModel survives configuration changes by design; to survive process death as well, pair it with SavedStateHandle.
Hoisting is not free. Two limits matter in practice.
Prop drilling
If you hoist everything to the root of your UI tree, intermediate composables become cluttered with parameters they do not use. This is prop drilling. The mitigation is to hoist to the lowest common ancestor that needs to coordinate the state—not necessarily the top of the tree.
Recomposition scope
When hoisted state changes, Compose recomposes the composables that read it. If the state type is not stable—meaning the Compose compiler cannot prove it is immutable—changing it can recompose more of the tree than necessary. For example, a plain List is treated as unstable because its contents can change. Use immutable data classes, kotlinx.collections.immutable types, or wrap derived values in derivedStateOf to keep recompositions narrow.
Actionable takeaway
Pick one composable in your codebase that owns mutableStateOf. Replace the internal state with value and onValueChange parameters. Move the remember or ViewModel call to the nearest caller that needs to read or write that state. Then write a single unit test that drives the callback and asserts the rendered output. If the component still needs to reset itself internally, reconsider whether that state truly belongs to the caller—but in most cases, the stateless contract will make the component easier to use everywhere else.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.