State Hoisting in Jetpack Compose
State Hoisting in Jetpack Compose
State hoisting is one of the foundational patterns in Jetpack Compose for building predictable, reusable, and testable UI components. The idea is straightforward: move state ownership out of a composable and into its caller, turning the composable into a stateless function that receives its current value and emits events when the user wants to change it. Where things get interesting is when the state in question is not a simple text field value but a complex object like LazyListState, which controls scroll position, item visibility, and programmatic scrolling for an entire list. Understanding where to hoist such state, how the lowest common ancestor rule applies, and when state belongs in a ViewModel versus a plain state holder is essential for writing well-structured Compose code. By the end of this lesson, you will be able to:
- Explain the state hoisting pattern and how it establishes a single source of truth in a composable tree.
- Describe the lowest common ancestor rule and apply it to determine where state should live.
- Trace how
LazyListStateinternals connect scroll position,firstVisibleItemIndex, and programmatic scrolling. - Identify the tradeoffs between hoisting state to a composable, a plain state holder class, and a ViewModel.
- Apply state hoisting to a real scenario where multiple composables need shared access to list scroll state.
The Mechanics of State Hoisting
In Compose, a composable that creates and holds its own state via remember { mutableStateOf(...) } is called a stateful composable. State hoisting transforms it into a stateless composable by replacing the internal state with two parameters: a value representing the current state, and a callback lambda (typically named onValueChange or a more specific name like onCheckedChange) that the composable invokes when the user performs an action that should change the state.
The canonical example is a text field:
// Stateful: owns its own state
@Composable
fun StatefulTextField() {
var text by remember { mutableStateOf("") }
TextField(value = text, onValueChange = { text = it })
}
// Stateless: state is hoisted to the caller
@Composable
fun StatelessTextField(value: String, onValueChange: (String) -> Unit) {
TextField(value = value, onValueChange = onValueChange)
}
The stateless version is more reusable because the caller controls how state is stored, validated, and shared. It is more testable because its output is a pure function of its inputs. And it enforces a single source of truth: there is exactly one variable holding the text value, located in the caller, eliminating any possibility of two pieces of state drifting out of sync.
This pattern creates unidirectional data flow. State flows down from the parent to the child as a parameter. Events flow up from the child to the parent as lambda invocations. The parent processes the event, updates its state, and Compose recomposes the child with the new value. The child never mutates state directly.
The Lowest Common Ancestor Rule
When multiple composables need access to the same piece of state, the question becomes: where should the state live? The rule is to hoist the state to the lowest common ancestor of all composables that read from or write to it. Hoisting lower than this means some composables cannot reach the state. Hoisting higher than necessary means the state is visible to parts of the tree that have no business interacting with it, which increases recomposition scope and muddies the ownership model.
Consider a chat screen with three components: a MessagesList (a LazyColumn displaying messages), a JumpToBottom button that scrolls the list to the most recent message, and a NewMessagesBanner that appears when new messages arrive while the user is scrolled up. All three need access to the same LazyListState:
MessagesListpasses it toLazyColumnso Compose can manage scroll position.JumpToBottomcallslistState.scrollToItem(0)to programmatically scroll.NewMessagesBannerreadslistState.firstVisibleItemIndexto decide whether the user is scrolled away from the bottom.
This interview continues for subscribers
Subscribe to Dove Letter for full access to exclusive interviews about Android and Kotlin development.
Become a Sponsor