Phase 2 · Building Interactive UIsModule 9~28 min read

State Management & State Hoisting

Make composables reusable and testable by hoisting state — the key Compose design pattern.

What you'll learn

In Phase 1 you met remember and mutableStateOf. Now we turn that into the single most important design pattern in Compose: state hoisting. Getting this right is what separates a tangle of copy-pasted composables from a clean, reusable, testable UI.

By the end you'll be able to:

  • Tell a stateful composable from a stateless one
  • Hoist state up to the right caller
  • Keep a single source of truth for every piece of state
  • Apply the "state down, events up" pattern deliberately

Stateful vs stateless

A composable is stateful when it holds state inside itself with remember. It's convenient — you can drop it anywhere — but it's a black box: the caller can't read the value, can't set it, and can't test the composable with a chosen value. Here's a text field that owns its own text:

NameField.kt
// STATEFUL: this composable owns and mutates its own state.
@Composable
fun NameField() {
    var name by remember { mutableStateOf("") }
    OutlinedTextField(
        value = name,
        onValueChange = { name = it },
        label = { Text("Name") },
    )
}

A stateless composable holds no state. It receives the value as a parameter and reports changes through a callback. It doesn't decide anything — it just renders what it's given and announces what happened:

NameField.kt
// STATELESS: no state inside. Value comes in, events go out.
@Composable
fun NameField(
    name: String,                       // state in
    onNameChange: (String) -> Unit,     // event out
) {
    OutlinedTextField(
        value = name,
        onValueChange = onNameChange,
        label = { Text("Name") },
    )
}

Key idea

A stateless composable is a pure function of its inputs: same parameters, same UI, every time. That makes it trivial to preview with any data, reuse in several screens, and test — the three things a stateful version fights you on.

Hoisting the state

State hoisting is the act of moving state out of a composable and up to its caller. You replace the internal remember with two parameters — the current value and an onValueChange callback — exactly as we did above. The caller then owns the state and passes it back down:

SignupScreen.kt
// The caller now owns the state — the single source of truth.
@Composable
fun SignupScreen() {
    var name by remember { mutableStateOf("") }

    Column {
        NameField(name = name, onNameChange = { name = it })
        Text("Hello, ${name.ifBlank { "stranger" }}!")   // reuses the same state
    }
}

Notice the payoff on the last line: because name now lives in the caller, the greeting Text can read the same value the field is editing. Two composables, one piece of state, always in sync — impossible when the state was locked inside NameField.

Single source of truth

The rule that keeps hoisting sane: every piece of state has exactly one owner — one place where it is declared and mutated. Everyone else receives a read-only copy and asks the owner to change it via a callback. If two composables each kept their own copy of "name," they would drift apart; with one source of truth they cannot.

Where should that owner be? Hoist state to the lowest common ancestor that:

  • needs to read the state, and
  • needs to change the state, and
  • is the common parent of everything that uses it.

Tip

Hoist just high enough — no higher. Push state all the way to the top "just in case" and every screen turns into one giant composable threading dozens of parameters. Keep each piece of state at the lowest level that still lets everyone who needs it share one copy.

State down, events up

This is the phrase to memorise. Data flows down as parameters; events flow up as callbacks. A child never reaches up to mutate a parent's state directly — it reports "the user typed this" and lets the owner decide what to do. This one-way flow is called unidirectional data flow, and it makes your UI predictable: to understand any screen, you only have to know its state and how events change it.

DirectionWhat flowsHow
Down ↓State (the current value)value: String
Up ↑Events (something happened)onValueChange: (String) -> Unit
The two halves of every hoisted composable.

When not to hoist

Hoisting isn't always right. If a piece of state is a private, internal detail that no parent and no sibling needs — the scroll position of a list, whether a dropdown is open, an animation's progress — keep it local with remember. Hoisting it would only add noise. The test is simple: does anyone else need to read or control this value? If no, keep it inside; if yes, hoist it.

Note

A great habit: write your reusable UI pieces (buttons, fields, cards) as stateless, and let the screen composable be the stateful one that owns the state and wires everything together. In Phase 3 that owner moves again — into a ViewModel — but the pattern is identical.

Recap & quick check

Key takeaways

  • A stateful composable holds its own state; a stateless one receives value and reports changes via a callback.
  • State hoisting moves state up to the caller — replacing an internal remember with value + onValueChange.
  • Keep a single source of truth: each piece of state has exactly one owner; everyone else gets a read-only copy.
  • State down, events up = unidirectional data flow, which makes the UI predictable.
  • Don't hoist purely-internal state (scroll position, an open/closed toggle) — keep it local with remember.

Quick check

1. What is a stateless composable?

2. State hoisting means…

3. Why keep a single source of truth?

4. 'State down, events up' describes…

5. Which state is best kept LOCAL rather than hoisted?

You now know the pattern every Compose app is built on. Next we'll put it to work capturing real user input — buttons, text fields, switches, and a validated form.