State Hoisting
The previous lesson's RecipeTitleField worked, but it had a real limitation: it kept
its title and touched state locked inside itself with
remember, so nothing outside that composable could read what the user had typed, and
nothing outside could set it either — not a parent screen, not a preview, not a test.
State hoisting is the fix: the pattern of moving state up to a caller and
passing it back down as a plain parameter plus a callback.
The Pattern: value Down, Events Up
A hoisted composable owns no state of its own. Instead of remember { mutableStateOf(...) }
} inside the function, it accepts the current value as a parameter, and instead of updating
that value itself, it reports the requested change upward through a callback:
// Not hoisted — RecipeTitleField owns its own state; the caller has no visibility into it
@Composable
fun RecipeTitleField() {
var title by remember { mutableStateOf("") }
OutlinedTextField(value = title, onValueChange = { title = it })
}
// Hoisted — the caller owns "title" and decides what happens when it changes
@Composable
fun RecipeTitleField(
title: String,
onTitleChange: (String) -> Unit
) {
OutlinedTextField(value = title, onValueChange = onTitleChange)
}
Nothing about what's drawn on screen changed — but the second version is what Compose documentation calls stateless: it has no memory of its own between recompositions. Every time it's called, its entire output is a pure function of the parameters it was given.
Why This Matters: Reusability and Testability
- Reusable — a stateless
RecipeTitleFieldcan be dropped into the Add Recipe screen, the Edit Recipe screen, and a search-by-title bar, each backed by whatever state source makes sense there (localremember, a ViewModel'sStateFlow, a saved-instance value) without changing the composable itself. - Single source of truth — when a parent screen owns
title, it can also validate it, save it, or reset it in one place, instead of reaching awkwardly into a child composable's private state. - Testable and previewable — a stateless composable can be rendered in a
@Previewor a test with any fixedtitleand a no-oponTitleChange, with no need to simulate typing just to see a particular value rendered.
Worked Example: Refactoring RecipeTitleField
Here's the full validation example from the TextField lesson, refactored so the caller — the
Add Recipe screen — owns title and decides when it counts as "touched":
// Stateless, hoisted: no remember, no internal state at all
@Composable
fun RecipeTitleField(
title: String,
onTitleChange: (String) -> Unit,
showError: Boolean,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = title,
onValueChange = onTitleChange,
label = { Text("Recipe title") },
isError = showError,
supportingText = {
if (showError) {
Text("Title is required")
}
},
modifier = modifier.fillMaxWidth()
)
}
// The caller now owns every piece of state RecipeTitleField needs
@Composable
fun AddRecipeScreen() {
var title by remember { mutableStateOf("") }
var touched by remember { mutableStateOf(false) }
Column(modifier = Modifier.padding(16.dp)) {
RecipeTitleField(
title = title,
onTitleChange = {
title = it
touched = true
},
showError = touched && title.isBlank()
)
Button(
onClick = { /* save recipe */ },
enabled = title.isNotBlank()
) {
Text("Save Recipe")
}
}
}
RecipeTitleField no longer has a single call to remember — every value it
renders, and every decision about what counts as an error, was handed to it from
AddRecipeScreen. That's what makes it safe to reuse on the Edit Recipe screen too:
AddRecipeScreen starts title from an empty string, while an
EditRecipeScreen would start it from the existing recipe's saved title — the exact
same RecipeTitleField composable works unchanged in both places, because it never
assumed where its value came from.