State Hoisting in Jetpack Compose: A Practical Guide with Examples

State hoisting is the pattern Compose uses instead of two-way data binding: rather than a component owning and mutating its own state, you move (“hoist”) that state up to a caller and pass it down as parameters, along with a callback for changing it. It’s the single most important pattern for writing reusable, testable Compose UI.

The Problem: A Composable That Owns Its Own State

@Composable
fun SearchField() {
    var query by remember { mutableStateOf("") }
    TextField(value = query, onValueChange = { query = it })
}

This works, but nothing outside SearchField can read the current query, clear it, or set it from elsewhere. You can’t filter a list based on it, and you can’t write a meaningful test for it without rendering the whole composable.

The Fix: Hoist the State

@Composable
fun SearchField(
    query: String,
    onQueryChange: (String) -> Unit
) {
    TextField(value = query, onValueChange = onQueryChange)
}

@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }

    Column {
        SearchField(query = query, onQueryChange = { query = it })
        Text("You searched for: $query")
    }
}

SearchField no longer holds any state of its own — it just displays whatever query it’s given and reports changes upward via onQueryChange. This is called a stateless composable, and SearchScreen, which owns the remember block, is the stateful one.

Why This Matters

  • Reusability: SearchField can now be used anywhere, wired up to any source of truth — a local remember, a ViewModel, or a value derived from something else entirely.
  • Single source of truth: the state lives in exactly one place, so there’s no risk of the UI and the underlying data drifting out of sync.
  • Testability: you can render SearchField in a test with a fixed query and a fake onQueryChange, with no need to simulate remember or recomposition.

How Far Up Should You Hoist?

Only as far as the state actually needs to go. A common mistake is hoisting everything all the way to a ViewModel, even for state that’s purely a UI concern and never used outside one screen — for example, whether a password field is currently obscured. That kind of state is fine living in remember at the screen level; it doesn’t need to survive a rotation or be visible to business logic.

The general rule: hoist state to the lowest common ancestor of every composable that needs to read or write it — no further.