RizTech Academy logo
RizTech Academy
State and LifecycleLesson 2 of 535 min

State hoisting, and stateless composables

PricesScreen does two jobs. It draws the search field, the chips and the cards, and it owns the search text and the chosen commodity. The second job is why you cannot preview the screen with "pune" already typed, why a test cannot set up a filtered screen without tapping through it, and why lesson 3 cannot move the state somewhere safer without rewriting the drawing. This lesson splits the jobs: one composable owns the state, and everything it draws is stateless, told what to show and reporting what the user did.

Stateless and stateful

A stateless composable holds no state of its own. Everything it shows arrives as parameters, and everything the user does leaves as calls to lambda parameters. A stateful composable creates and keeps state, with remember or something stronger.

You have used a stateless composable since module 3. OutlinedTextField does not keep its text: module 3 passed it value = "", typed "onion", and watched the field stay empty, because the field shows the value it is given and reports each edit through onValueChange. That pair, a value going down and an event coming up, is the whole pattern. Moving state out of a composable and replacing it with that pair is called state hoisting: you lift the state up to the caller.

PriceCard is stateless too, and always has been: it takes a CommodityPrice and draws it. That is why module 3 could preview it with @PreviewParameter and module 4 could preview it at 1.5× font scale without running the app. A stateless composable can be previewed in every state it can ever be in, because a state is only a set of arguments.

The search field and the chips, stateless

Create ui/FilterControls.kt:

package com.riztechacademy.mandibhav.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.riztechacademy.mandibhav.ui.theme.MandiBhavTheme

/**
 * The search box. Stateless: it shows [query] and reports each edit to
 * [onQueryChange]; whoever calls it decides what to keep.
 */
@Composable
fun PriceSearchField(
    query: String,
    onQueryChange: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        label = { Text("Search commodity or market") },
        singleLine = true,
        modifier = modifier,
    )
}

/**
 * "All" plus one chip per commodity. Stateless: [selected] is the chosen
 * commodity, or null for all, and a tap is reported to [onCommodityClick]
 * (null for "All"). Tapping the selected chip again is reported too; the
 * caller decides that it clears the filter.
 */
@Composable
fun CommodityChips(
    commodities: List<String>,
    selected: String?,
    onCommodityClick: (String?) -> Unit,
    modifier: Modifier = Modifier,
) {
    FlowRow(
        modifier = modifier,
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        FilterChip(
            selected = selected == null,
            onClick = { onCommodityClick(null) },
            label = { Text("All") },
        )
        for (commodity in commodities) {
            FilterChip(
                selected = selected == commodity,
                onClick = { onCommodityClick(commodity) },
                label = { Text(commodity) },
            )
        }
    }
}

@Preview(showBackground = true, widthDp = 360)
@Composable
private fun FilterControlsPreview() {
    MandiBhavTheme {
        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            PriceSearchField(
                query = "pune",
                onQueryChange = {},
                modifier = Modifier.fillMaxWidth(),
            )
            CommodityChips(
                commodities = listOf("Onion", "Tomato", "Potato", "Wheat", "Soybean", "Tur dal"),
                selected = "Tomato",
                onCommodityClick = {},
            )
        }
    }
}

The preview shows "pune" in the field and "Tomato" selected, with no tapping. The lambdas are {} because a preview has nobody to report to.

Three decisions worth naming:

  • Parameter order follows the Compose convention: required values and their events first, then modifier as the first optional parameter. PriceSearchField applies the caller's modifier to its outermost element and nothing else, as module 3 taught for PriceCard.
  • The chips report a tap, not a decision. onCommodityClick("Onion") means "the user tapped Onion". Whether tapping a selected chip clears it is a rule about filtering, not about chips, so it belongs to whoever owns the filter. A different screen could use the same chips with different rules.
  • The names say what happened. onQueryChange and onCommodityClick describe the event. Avoid names like updateQuery on a composable: they describe what the caller will do, which the composable has no business knowing.

One owner: PricesRoute

Now PricesScreen stops owning anything. Its new signature takes the two values and the two events, plus the prices to show and the chip names. Above it, a small stateful composable owns the state. In ui/PricesScreen.kt:

@Composable
fun PricesRoute(modifier: Modifier = Modifier) {
    // The one owner of the screen's state. Lesson 3 moves it into a ViewModel.
    var query by remember { mutableStateOf("") }
    var selectedCommodity by remember { mutableStateOf<String?>(null) }

    val prices = SamplePrices.all
    PricesScreen(
        prices = filterPrices(prices, query, selectedCommodity),
        commodities = commoditiesIn(prices),
        query = query,
        selectedCommodity = selectedCommodity,
        onQueryChange = { query = it },
        onCommodityClick = { commodity ->
            selectedCommodity = if (commodity == selectedCommodity) null else commodity
        },
        modifier = modifier,
    )
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PricesScreen(
    prices: List<CommodityPrice>,
    commodities: List<String>,
    query: String,
    selectedCommodity: String?,
    onQueryChange: (String) -> Unit,
    onCommodityClick: (String?) -> Unit,
    modifier: Modifier = Modifier,
    windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass,
) {
    val columns = if (
        windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
    ) 2 else 1

    Scaffold(
        modifier = modifier,
        topBar = {
            TopAppBar(title = { Text(text = stringResource(R.string.app_name)) })
        },
    ) { innerPadding ->
        Column(
            modifier = Modifier
                .padding(innerPadding)
                .consumeWindowInsets(innerPadding)
                .imePadding()
                .verticalScroll(rememberScrollState())
                .padding(horizontal = 16.dp, vertical = 8.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            PriceSearchField(
                query = query,
                onQueryChange = onQueryChange,
                modifier = Modifier.fillMaxWidth(),
            )
            CommodityChips(
                commodities = commodities,
                selected = selectedCommodity,
                onCommodityClick = onCommodityClick,
            )
            if (prices.isEmpty()) {
                Text(
                    text = "No prices match \"${query.trim()}\".",
                    style = MaterialTheme.typography.bodyLarge,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }
            // The rows of PriceCards from module 4 stay exactly as they were,
            // except that they loop over prices instead of visible.
            for (rowOfPrices in prices.chunked(columns)) {
                // …
            }
        }
    }
}

The Row of cards inside the loop is unchanged from module 4; only its list is renamed. In PricesScreen, prices now means "the prices to show", already filtered, so the screen no longer calls filterPrices or commoditiesIn at all. FlowRow, FilterChip and OutlinedTextField move out of this file's imports, and remember, mutableStateOf, getValue and setValue stay, now used by PricesRoute.

MainActivity calls the owner instead of the screen:

setContent {
    MandiBhavTheme {
        PricesRoute(modifier = Modifier.fillMaxSize())
    }
}

and drops the SamplePrices and PricesScreen imports for com.riztechacademy.mandibhav.ui.PricesRoute.

Update the two existing previews of PricesScreen to pass all six values, and add one that module 4 could not have:

@Preview(name = "Filtered: tomato in Pune", showBackground = true, showSystemUi = true)
@Composable
private fun PricesScreenFilteredPreview() {
    MandiBhavTheme {
        PricesScreen(
            prices = filterPrices(SamplePrices.all, query = "pune", commodity = "Tomato"),
            commodities = commoditiesIn(SamplePrices.all),
            query = "pune",
            selectedCommodity = "Tomato",
            onQueryChange = {},
            onCommodityClick = {},
        )
    }
}

Run the app. Type "pune", tap "Tomato": the emulator showed one card, Tomato at Pune (Gultekdi) APMC, exactly as before. Hoisting changed who owns the state, not what the user sees.

Unidirectional data flow

Draw what now happens when the farmer taps "Tomato":

              PricesRoute  (owns query, selectedCommodity)
                 │    ^
   state flows   │    │   events flow up
   down as       │    │   as lambda calls:
   parameters:   │    │   onCommodityClick("Tomato")
   prices, query,│    │
   selected…     v    │
              PricesScreen  (stateless)
                 │    ^
                 v    │
       CommodityChips / PriceSearchField / PriceCard
  1. FilterChip's onClick calls onCommodityClick("Tomato").
  2. That lambda, passed down from PricesRoute, sets selectedCommodity.
  3. The write recomposes PricesRoute, which calls PricesScreen with the new filtered list and the new selection.
  4. CommodityChips draws "Tomato" as selected because it was told to.

The chip never changes its own appearance. It asks, and the owner decides. This is unidirectional data flow: state only travels down, events only travel up. The cost is a little indirection. What it buys is that there is exactly one place where the filter can change, so when the screen shows the wrong thing there is one place to look.

The trap: two sources of truth

Hoisting goes wrong in a quiet way when a composable hoists the event but keeps its own copy of the state "for convenience". Change CommodityChips in a scratch copy so it remembers which chip it highlighted:

// The trap: a second copy of the selection, kept inside the chips.
var shown by remember { mutableStateOf<String?>(null) }

and use shown instead of selected for each chip's selected, setting shown = commodity in each onClick before calling onCommodityClick(commodity).

On the emulator, tap "Onion": one copy says Onion, the other says Onion, all is well. Tap "Onion" again. PricesRoute applies its rule and clears the filter, so all nine prices came back, Tomato cards included. The chip row still showed Onion highlighted. Two pieces of state described the same thing, only one of them knew the rule, and the screen now contradicted itself. Nothing crashed and no log line mentioned it.

Every piece of state should have exactly one owner, and everything else should read it from there. If you find yourself copying a parameter into a remember so you can change it, that is the moment to stop and hoist instead.

Where state should live

Hoisting does not mean "move everything to the top". The rule is to hoist state to the lowest common ancestor of everything that reads or writes it:

State Read or written by Lives in
Search text, chosen commodity The field, the chips, and the filtering PricesRoute (lesson 3: a ViewModel)
Scroll position Only the scrolling Column PricesScreen, with rememberScrollState()
Whether a dropdown is open Only that dropdown The dropdown itself

The scroll position stays in PricesScreen. Nothing outside that screen reads it, and it is UI element state: it describes the screen, not the user's data. Lesson 5 hoists it one step to add a "Back to top" button, and no further.

What hoisting costs

PricesScreen went from three parameters to eight. Each new filter adds a value and an event, and a screen with ten controls ends up with twenty parameters. That is real, and module 7 ("Modelling UI state as one object") answers it by grouping the values into one class. The lambdas cost a little too: each is an object, though Compose's strong skipping remembers lambdas for you, so passing them down does not stop skipping.

What you get for it: PricesScreen, CommodityChips and PriceSearchField can each be previewed in any state, and in the testing module, a UI test can hand PricesScreen a filtered list and check the cards without typing anything.

Rotate the emulator now. The search is still lost: PricesRoute uses remember, and owning state in a better place is not the same as keeping it. That is lesson 3.

Check your work

What makes a composable stateless. It holds no state of its own: it shows its parameters and reports events through lambdas.

What state hoisting is. Replacing a composable's internal state with a value parameter and an event parameter, so the caller owns the state.

Why the chips report a tap rather than toggling themselves. Toggling is a filtering rule. Keeping it with the owner means one place decides what the filter is.

Why PricesScreen no longer calls filterPrices. It receives the already-filtered list; filtering belongs to whoever owns the filter.

The direction of state and of events. State flows down as parameters; events flow up as lambda calls.

What went wrong with the chips' own remember. Two copies of the selection disagreed: the list showed every price while the Onion chip stayed highlighted.

Why the scroll position stays in PricesScreen. Nothing else reads it; it is UI element state, so it lives as low as it can.

Practice 3. Six parameters, so six arguments: prices = emptyList(), commodities = commoditiesIn(SamplePrices.all), query = "vashi onion", selectedCommodity = null and two {} lambdas. The preview shows the "No prices match" text under the chips, and nothing has to be typed to see it.

Practice 5. One answer, in PriceSearchField:

OutlinedTextField(
    value = query,
    onValueChange = onQueryChange,
    label = { Text("Search commodity or market") },
    singleLine = true,
    trailingIcon = if (query.isNotEmpty()) {
        { TextButton(onClick = { onQueryChange("") }) { Text("Clear") } }
    } else {
        null
    },
    modifier = modifier,
)

with the import androidx.compose.material3.TextButton. On the emulator "Clear" appeared after typing "pune" and tapping it brought all nine prices back. No new parameter is needed: clearing is an edit to the query, so it is reported through the same onQueryChange. A separate onClear would be a second way to change the same state.

Practice

  1. Create FilterControls.kt, make PricesScreen stateless, add PricesRoute, and switch MainActivity to it. Run it and check the screen behaves exactly as before.
  2. Open the filtered preview and the FilterControlsPreview in Android Studio's split view. Change selected in the preview to "Wheat" and watch it update without running the app.
  3. Add a preview of PricesScreen showing the empty state: a query that matches nothing.
  4. Break it on purpose: give CommodityChips its own remember copy of the selection as above, tap "Onion" twice, and see the chips and the list disagree. Then delete the copy.
  5. Harder: add a "Clear" text button inside the search field that appears only when there is text, using OutlinedTextField's trailingIcon slot and a TextButton. Keep PriceSearchField stateless. Decide whether it needs a new parameter, and argue for your answer.

Official documentation

Next: a ViewModel, so the search and the chosen commodity survive the phone being rotated.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship