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 RecipeTitleField can 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 (local remember, a ViewModel's StateFlow, 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 @Preview or a test with any fixed title and a no-op onTitleChange, 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.

Rule of thumb: Not everything needs to be hoisted all the way to the top of your app. Hoist state only as far up as the components that actually need to read or react to it — hoisting further than necessary just adds parameters for no benefit.
Next up: Every RecipeBox screen so far has shown one recipe at a time. LazyColumn & LazyRow covers efficiently rendering RecipeBox's actual recipe list, no matter how many recipes it holds.