State Hoisting in Jetpack Compose: Make Your UI Stateless and Testable
State hoisting moves mutable state out of composables, turning them into pure UI functions. Learn why, how, and when to hoist state in Jetpack Compose for better testability and reuse.
09 Oct 2026, 11:55 UTC

Problem: The Black‑Box Composable
When a composable keeps its own mutableStateOf, the value lives inside a UI function. The parent component has no way to know the current value, and unit tests must simulate user interaction to reach a desired state. This makes the component hard to reuse, hard to test, and fragile when the UI grows.
What Is State Hoisting?
State hoisting is the pattern of moving mutable state out of a composable and into its caller. The child becomes a pure function that accepts two parameters:
- value – the current state to display.
- onValueChange – a lambda that the child calls whenever the state should change.
With this unidirectional data flow, the parent owns the state, the child only renders, and business logic can live in a ViewModel or higher‑level composable.
Worked Example: A Stateless Search Field
Below you’ll see a SearchInput that is completely stateless, and a SearchScreen that hoists the state to a ViewModel‑like level.
@Composable
fun SearchInput(
query: String,
onQueryChange: (String) -> Unit
) {
TextField(
value = query,
onValueChange = onQueryChange,
label = { Text("Search items…") }
)
}
@Composable
fun SearchScreen() {
// State lives here, so it survives recompositions.
var searchQuery by remember { mutableStateOf("") }
Column {
SearchInput(
query = searchQuery,
onQueryChange = { newValue -> searchQuery = newValue }
)
Spacer(modifier = Modifier.height(8.dp))
if (searchQuery.isEmpty()) {
Text("Please enter a search term")
} else {
Text("Searching for: $searchQuery")
}
}
}
Because SearchInput only depends on its parameters, you can preview or test it by passing a hard‑coded string:
@Composable
fun PreviewSearchInput() {
SearchInput(
query = "Compose",
onQueryChange = {} // no-op for preview
)
}
Trade‑offs to Watch For
Prop Drilling
If you hoist state too high—say, all the way to the activity—you’ll end up passing the value and callback through many composables that don’t use them. This verbosity can make the code harder to read and maintain.
Unnecessary Recomposition
When state changes, the nearest composable that owns it recomposes. If that owner is large, the entire subtree may be recomposed. Hoist only as high as the lowest common ancestor that actually needs the data.
Practical Decision Guide
Use the table below to decide where to hoist based on the component’s responsibilities.
| Scenario | Typical Hoist Level | Why |
|---|---|---|
| Local UI animation state | Internal (within the composable) | Only the composable needs it. |
| Form field that drives validation | Parent composable or ViewModel | Parent controls button enablement. |
| User profile data that survives rotation | ViewModel | State survives configuration changes. |
Actionable Next Steps
- Audit your composables: look for
remember { mutableStateOf }that isn’t used by the composable itself. - Move the state to the nearest parent that needs it, adding a
valueandonChangeparameter to the child. - In tests, pass concrete values to the stateless composable and assert the UI renders correctly without simulating user input.
- If you hoist to a
ViewModel, remember to expose the state viaStateFloworLiveDataand collect it in the composable.
By following this pattern, your UI components become pure, reusable building blocks that are easier to test and maintain.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.