Hoisting UI State in Jetpack Compose: Making Composables Pure and Testable
Learn how to hoist UI state out of Jetpack Compose composables to make them pure, reusable, and easy to test, with a concrete counter‑button example and verification steps.
11 Jun 2026, 11:06 UTC

The problem: state tangled inside composables
When you declare a var or mutableStateOf directly inside a composable, that composable becomes responsible for both UI rendering and state management. The result is a component that is hard to reuse in different contexts, difficult to unit‑test because its behavior depends on hidden internal state, and fragile when the UI logic evolves.
Thesis: lift state out to make composables pure functions
State hoisting means moving the source of truth to a common ancestor (often a parent composable or a ViewModel) and passing the current value down as an immutable parameter, together with an event callback for changes. The child composable then depends only on its inputs, making it a pure function that can be previewed, tested, and composed freely.
Identify the state to hoist
Look for any var, mutableStateOf, or rememberSaveable that changes over time and influences what the UI shows. In a typical counter button, the state is the integer that tracks the number of clicks.
Example: a stateful counter button
@Composable
fun CounterButton() {
var count by remember { mutableStateOf(0) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Count: $count")
Button(onClick = { count++ }) { Text("Increment") }
}
}
The composable owns count and mutates it directly.
Refactor to a stateless composable
Extract the UI into a function that receives the current value and a click handler as parameters. No state is stored inside.
@Composable
fun StatelessCounterButton(
count: Int,
onIncrement: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Count: $count")
Button(onClick = onIncrement) { Text("Increment") }
}
}
Now the button is pure: given the same count and onIncrement, it always produces the same UI.
Hoist the state to a parent or ViewModel
The parent (or a ViewModel) holds the mutable state and provides the callback.
@Composable
fun MainScreen(viewModel: CounterViewModel = viewModel()) {
val count by viewModel.count.collectAsState()
StatelessCounterButton(
count = count,
onIncrement = { viewModel.increment() }
)
}
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow = _count.asStateFlow()
fun increment() { _count.value += 1 }
}
The ViewModel survives configuration changes, so the counter value is retained automatically. If you prefer to stay in the composition tree, you can hoist with remember { mutableStateOf(0) } in the parent composable instead of a ViewModel.
Verification steps
- Run the app on an emulator or device. Click the button and observe the number increase.
- Use Compose Preview to render
StatelessCounterButtonwith differentcountvalues (e.g., 0, 5, 10) and confirm the text updates without any internal state. - Unit test the stateless composable with
composeTestRule:@Test fun counterButton_showsCorrectCount() { composeTestRule.setContent { StatelessCounterButton(count = 42, onIncrement = {}) } composeTestRule.onNodeWithText("Count: 42").assertExists() }
Trade‑offs and limitations
- Boilerplate: Hoisting adds parameters and a callback for each piece of state. Deeply nested UI can lead to long parameter lists (“prop drilling”).
- Unnecessary recompositions: If a parent passes down a stable object that changes frequently, all children may recompose. Mitigate this by extracting derived values with
derivedStateOfor by splitting the tree withkeyto isolate recompositions. - Cross‑tree data: For state needed by many unrelated branches (e.g., theme, user session), consider
CompositionLocalor a ViewModel‑scoped holder instead of threading parameters through every level.
Balance purity with performance: hoist state when the benefit of testability and reusability outweighs the extra parameters; keep UI‑local, transient state (like a temporary text field focus) inside the composable when appropriate.
Actionable closing
Pick one screen in your current project that contains a mutable variable inside a composable. Apply the steps above: identify the state, extract a stateless version, hoist the state to a parent or ViewModel, and verify with a preview and a unit test. You’ll immediately see the composable become easier to preview in different configurations and to test in isolation, laying the groundwork for a more maintainable Compose codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.