State hoisting is one question: who else needs to know?

State hoisting means moving state to the lowest common ancestor of everything that needs it. The trigger is a second reader — not a rule to apply preemptively.

6 min read
androidcomposekotlinarchitecture

Day 11 — State hoisting is one question

Day 11 of 100, and the first of three posts on state hoisting — the decision that shapes a Compose codebase more than any other.

The symptom

A search field owns its own text. Reasonable, until a second thing needs it.

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

@Composable
fun SearchScreen() {
    Column {
        SearchField()
        ResultsList(query = ???)      // no way to reach it
    }
}

The state is locked inside SearchField. The results list can't see it, and there's no way to reach in — Day 5's point: there is no field object to interrogate.

Why the obvious fix fails

The obvious fix is a callback that copies the value outward:

@Composable
fun SearchField(onQueryChange: (String) -> Unit) {
    var query by remember { mutableStateOf("") }
    TextField(value = query, onValueChange = { query = it; onQueryChange(it) })
}

@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }   // a SECOND copy
    Column {
        SearchField(onQueryChange = { query = it })
        ResultsList(query = query)
    }
}

This works, and it has created two sources of truth for one value.

They agree today because one always writes the other. They stop agreeing the moment anything else wants to set the query — a "clear" button, a deep link, restoring saved state. Set the parent's copy and the field still shows the old text, because the field's own remember never heard about it.

That's not hypothetical; it's the most common Compose state bug, and it always presents as "the text field won't clear".

State hoisting moves state up to the lowest common ancestor and passes value down, events up

The actual mechanism

Hoisting means moving state up to the lowest common ancestor of everything that needs it, and making the child stateless:

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

@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }      // ONE source of truth
    Column {
        SearchField(query = query, onQueryChange = { query = it })
        ResultsList(query = query)
    }
}

The shape has a name — value down, events up — and three consequences worth naming individually:

Single source of truth. One query exists. Clearing it from anywhere updates everything, because there is nothing else to update.

The child becomes testable and previewable. SearchField("hello") {} renders a specific state with no setup. Every state is one call away, which is what makes @Preview genuinely useful rather than decorative.

The child becomes reusable. It no longer assumes where its value lives. The same composable serves a screen backed by a ViewModel, a dialog holding local state, and a preview holding a literal.

The part that gets over-applied

Hoisting is a decision with a trigger, not a rule to apply preemptively.

The trigger is: something other than this composable needs to read or write the value. Until that is true, local state is correct, and hoisting it early makes things worse:

// Over-hoisted: nothing outside cares whether this dropdown is open
@Composable
fun Screen() {
    var menuOpen by remember { mutableStateOf(false) }      // why is this here?
    var scrollPos by remember { mutableStateOf(0) }
    var tooltipVisible by remember { mutableStateOf(false) }
    …
}

Every one of those reads happens in Screen's scope, so — Day 7 — every one of them invalidates the whole screen. Over-hoisting is not just noise; it's a measurable performance cost.

The test I'd apply: if I deleted this composable, would the value still mean anything? Menu-open dies with the menu — keep it local. A selected user id outlives any one widget — hoist it.

Hoist to where, exactly?

"Up" has a specific destination: the lowest common ancestor of every reader and writer. Not the top of the screen, and not the ViewModel by default.

SearchScreen          ← query lives here: lowest ancestor of both readers
├── SearchField       reads + writes query
└── ResultsList       reads query

If only SearchField used it, it belongs in SearchField. If a bottom sheet on another screen needed it too, it moves further up — or out of the composition entirely, which is Day 12.

Hoisting higher than necessary is the same mistake as not hoisting at all, in the opposite direction: a wider invalidation scope and a parameter threaded through composables that have no interest in it.

The stateful/stateless pair

There's a middle position that gets used less than it should. When a component is usually self-contained but occasionally needs its state controlled from outside, ship both versions:

// Stateless — the real implementation
@Composable
fun SearchField(query: String, onQueryChange: (String) -> Unit) { … }

// Stateful — convenience overload for the common case
@Composable
fun SearchField() {
    var query by remember { mutableStateOf("") }
    SearchField(query = query, onQueryChange = { query = it })
}

Callers who don't care get one line. Callers who need control reach for the stateless one. This is exactly how the Compose library itself is built — Scaffold takes a scaffoldState you can supply or let default to rememberScaffoldState(), and every rememberXState() function in the API exists for the same reason.

The convention that comes with it: when a component takes a hoisted value, the corresponding callback should be named on<Value>Change, and it should be the value's only mutation path. A component that takes query and also mutates it internally has quietly reintroduced the two-copies bug behind a hoisted-looking signature.

How to prove it

The single-source-of-truth property is testable in about a minute. Add a clear button to both versions:

Button(onClick = { query = "" }) { Text("Clear") }

In the two-copies version, the results clear and the text field doesn't. In the hoisted version, both clear, because there's only one value.

That divergence is the bug the pattern exists to prevent, and seeing it once makes the rule stick better than any diagram.

The compile-time check is cheaper still: try writing a @Preview for the component in a specific state. If you can't — if the only way to see "search field with three characters typed" is to run the app and type — the state isn't hoisted far enough. Every stateless composable is trivially previewable, and that's not a coincidence; it's the same property viewed from a different angle.

What this generalizes to

This is unidirectional data flow, which predates Compose by a long way — Redux, Elm, MVI all encode the same constraint. State flows down, events flow up, and no component mutates state it doesn't own.

The reason it keeps being rediscovered is that the alternative — components owning state that others also need — produces exactly one bug, endlessly: two copies that disagree. Hoisting doesn't make that bug rarer; it makes it unrepresentable.

Tomorrow, Day 12: when the state should leave the composition entirely and live in a ViewModel — and the tests for knowing which side of that line you're on.


Day 11 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Where to hoist state.