Stopping State Leakage: Implementing State Hoisting in Jetpack Compose
Learn how to implement state hoisting in Jetpack Compose to move from rigid, stateful components to flexible, stateless ones that are easier to test and control.
30 Oct 2025, 23:31 UTC

The Problem: The "Black Box" Component
You build a custom search bar component with its own internal remember { mutableStateOf("") }. It works perfectly in isolation. However, the moment you need to clear that search bar from a "Reset" button located in a different part of the screen, you hit a wall. Because the state is trapped inside the component, the parent has no way to reach in and change it.
This is the core problem with stateful composables: they are black boxes. To make components reusable and controllable, you need to move the state upward. This pattern is called State Hoisting.
What is State Hoisting?
State hoisting is the act of moving a component's state to its caller. Instead of a composable managing its own variables, it receives its current value as a parameter and notifies the parent when a change is requested via a callback (a lambda function). This transforms a stateful composable into a stateless one.
By hoisting state, you establish a single source of truth. The parent manages the data, and the child simply renders what it is told to render. This prevents synchronization bugs where two different components think the application is in two different states.
From Stateful to Stateless: A Practical Refactor
Consider a simple text input. In a stateful version, the component manages its own text. In a hoisted version, we replace the internal state with a value and an event.
The Stateful Approach (Hard to Test/Control)
@Composable
fun SearchInput() {
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it },
label = { Text("Search") }
)
}
The Hoisted Approach (Flexible and Testable)
To hoist this, we remove the remember block and introduce two parameters: value (the state) and onValueChange (the event).
@Composable
fun SearchInput(
text: String,
onTextChange: (String) -> Unit
) {
TextField(
value = text,
onValueChange = onTextChange,
label = { Text("Search") }
)
}
@Composable
fun SearchScreen() {
// The state now lives here, or in a ViewModel
var searchQuery by remember { mutableStateOf("") }
Column {
SearchInput(
text = searchQuery,
onTextChange = { searchQuery = it }
)
Button(onClick = { searchQuery = "" }) {
Text("Clear Search")
}
}
}
Engineering Trade-offs
While hoisting is a best practice, it is not a silver bullet. There are two primary risks to manage:
- Prop Drilling: If you hoist state too high, you may find yourself passing a variable through five layers of components just to reach a child that needs it. If the intermediate components don't use the state, you are "prop drilling." In these cases, consider using a
ViewModelor a composition-local provider. - Recomposition Overload: When state is hoisted to a high-level parent, any change to that state triggers a recomposition of the parent and potentially all its children. To mitigate this, ensure you use stable data types or
derivedStateOfto prevent unnecessary UI refreshes.
Verifying the Implementation
To verify that your state hoisting is working correctly, perform these three checks:
- External Control: Trigger a state change from the parent (e.g., clicking a "Reset" button) and verify the child UI updates immediately.
- Isolation Testing: Write a Compose UI test that passes a hardcoded string to the stateless composable. If the component renders that exact string without needing to simulate user typing, it is truly stateless.
- Event Propagation: Use a debugger or log statement in the parent's callback to ensure that user interactions in the child are correctly reaching the source of truth.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.