State Hoisting in Jetpack Compose with Real Examples

Quick answer: Hoist state to the lowest common ancestor that needs to read or change it. Expose the current value downward and events upward. Keep simple, private UI element state local; move screen state and business-driven state to a screen-level state holder such as a ViewModel.

State hoisting is the Compose pattern that separates what the UI shows from who owns the value. Instead of a child silently changing its own private value, the child receives state through parameters and requests changes through callbacks. The parent—or another state holder—decides what happens next.

The official state-hoisting guidance gives the most useful rule: hoist UI state to the lowest common ancestor of all composables that read and write it, while keeping the state as close as possible to where it is consumed.

The state-hoisting pattern

A stateless component usually follows this shape:

@Composable
fun NotificationToggle(
    enabled: Boolean,
    onEnabledChange: (Boolean) -> Unit,
    modifier: Modifier = Modifier,
) {
    Switch(
        checked = enabled,
        onCheckedChange = onEnabledChange,
        modifier = modifier,
    )
}

The component reads enabled, renders it, and reports a proposed new value. It does not decide where the value lives or whether the request should be accepted. That makes it reusable in a preview, test, screen, or dialog.

The general form is:

value: T
onValueChange: (T) -> Unit

Use more specific events when they communicate intent better, such as onDismiss(), onQuantityIncrease(), or onRetry(). The official Compose state guide recommends the generic value/callback pair as a pattern, not as a requirement for every component.

Stateful and stateless versions can coexist

The stateless API should usually be the core component. A small stateful wrapper can still be convenient when local ownership is appropriate:

@Composable
fun NotificationToggleStateful(
    modifier: Modifier = Modifier,
) {
    var enabled by rememberSaveable { mutableStateOf(false) }

    NotificationToggle(
        enabled = enabled,
        onEnabledChange = { enabled = it },
        modifier = modifier,
    )
}

This is not an anti-pattern. It is useful when no other composable needs the value and no business rule depends on it. The stateful wrapper owns the value; NotificationToggle remains reusable and easy to verify.

Use remember for short-lived composition state. Use rememberSaveable for simple UI state that should survive activity recreation and system-initiated process recreation, provided it can be saved to a Bundle or has an appropriate Saver. The state-saving documentation explains the available persistence options and their limits.

Example: share a search query between siblings

State must move upward when multiple siblings need it. A search field and a clear button both read and modify the same query, so their parent is the lowest common ancestor.

@Composable
fun ContactsToolbar(
    query: String,
    onQueryChange: (String) -> Unit,
    onClearQuery: () -> Unit,
) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        OutlinedTextField(
            value = query,
            onValueChange = onQueryChange,
            modifier = Modifier.weight(1f),
            label = { Text("Search contacts") },
        )
        if (query.isNotEmpty()) {
            IconButton(onClick = onClearQuery) {
                Icon(Icons.Outlined.Clear, contentDescription = "Clear search")
            }
        }
    }
}

@Composable
fun ContactsScreen() {
    var query by rememberSaveable { mutableStateOf("") }

    ContactsToolbar(
        query = query,
        onQueryChange = { query = it },
        onClearQuery = { query = "" },
    )

    // Another child can receive `query` to render matching contacts.
}

There is one source of truth: ContactsScreen. Both children stay synchronized because neither keeps a second copy of the query. The screen can also intercept an event—for example, trim input or send it to a business state holder—before storing it.

Do not hoist state that nobody else needs

Hoisting adds parameters and moves responsibility upward. That is useful only when another part of the UI, UI logic, or business logic needs the state.

This simple expandable message can own its own UI element state:

@Composable
fun ChatBubble(message: Message) {
    var showTimestamp by rememberSaveable { mutableStateOf(false) }

    Column(
        modifier = Modifier.clickable { showTimestamp = !showTimestamp },
    ) {
        Text(message.text)
        if (showTimestamp) {
            Text(message.sentAtLabel)
        }
    }
}

No other composable reads or changes showTimestamp, and the logic is local and simple. Keeping it here is clearer than threading a Boolean and callback through unrelated parents. If another element must open, close, or reflect the bubble’s details, hoist it then.

Example: put screen and business state in a ViewModel

When state is driven by repositories, use cases, validation, or other business rules, the lowest common ancestor is commonly outside the Composition. A screen-level ViewModel is a suitable owner.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

data class CheckoutUiState(
    val quantity: Int = 1,
    val isSubmitting: Boolean = false,
)

class CheckoutViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(CheckoutUiState())
    val uiState: StateFlow<CheckoutUiState> = _uiState.asStateFlow()

    fun onQuantityChanged(quantity: Int) {
        _uiState.update { it.copy(quantity = quantity.coerceAtLeast(1)) }
    }

    fun submitOrder() {
        _uiState.update { it.copy(isSubmitting = true) }
        // Delegate the actual order submission to a use case or repository.
    }
}

@Composable
fun CheckoutRoute(viewModel: CheckoutViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    CheckoutContent(
        state = state,
        onQuantityChanged = viewModel::onQuantityChanged,
        onSubmit = viewModel::submitOrder,
    )
}

@Composable
fun CheckoutContent(
    state: CheckoutUiState,
    onQuantityChanged: (Int) -> Unit,
    onSubmit: () -> Unit,
) {
    QuantitySelector(
        quantity = state.quantity,
        onQuantityChange = onQuantityChanged,
    )
    Button(onClick = onSubmit, enabled = !state.isSubmitting) {
        Text("Place order")
    }
}

The Route composable is the boundary that knows about the ViewModel. CheckoutContent only receives renderable state and callbacks, so it is straightforward to preview and test. Do not pass a ViewModel down through every reusable child; pass the small state and event API each child actually needs. For more on that boundary, see Compose state vs. ViewModel.

The example keeps the state update local so the data flow is visible. In a production app, submitOrder() would delegate to a use case or repository; collect the ViewModel flow in Compose with lifecycle awareness using collectAsStateWithLifecycle().

Example: hoist Compose-owned list state for UI logic

Some state belongs to Compose because it owns UI behavior such as scrolling. LazyListState is a good example. Make it a parameter with a remembered default:

@Composable
fun MessagesList(
    messages: List<Message>,
    listState: LazyListState = rememberLazyListState(),
    modifier: Modifier = Modifier,
) {
    LazyColumn(
        state = listState,
        modifier = modifier,
    ) {
        items(messages, key = { it.id }) { message ->
            MessageRow(message)
        }
    }
}

Most callers can use MessagesList(messages). A conversation screen that also has a “jump to bottom” control can create val listState = rememberLazyListState() and pass the same state to both the list and its UI logic. The default parameter keeps the component flexible without forcing every caller to care about scrolling. This pattern mirrors the official state-hoisting example.

Keep LazyListState, ScrollState, and similar Compose UI state in the Composition or a UI-scoped plain state holder unless business logic genuinely needs to observe it. Calling UI state-holder suspend methods from a viewModelScope can also be unsafe because their animations may require the Composition’s frame clock.

When a plain state holder helps

One Boolean can stay inline. Several related UI-only values and operations can make a screen composable hard to read. In that case, use a plain state holder class remembered in the Composition.

@Stable
class FiltersState(
    initialShowArchived: Boolean,
) {
    var showArchived by mutableStateOf(initialShowArchived)
        private set

    fun toggleArchived() {
        showArchived = !showArchived
    }
}

@Composable
fun rememberFiltersState(
    initialShowArchived: Boolean = false,
): FiltersState = remember(initialShowArchived) {
    FiltersState(initialShowArchived)
}

This separates UI logic from rendering while preserving the UI lifecycle. If the class needs state restoration, provide a Saver and use rememberSaveable; if it begins applying business logic or preparing screen data, move that responsibility to the screen-level state holder instead.

Choose the owner with this decision table

SituationRecommended owner
One small UI detail, used only by one composableKeep it local with remember or rememberSaveable
Several siblings need the same UI valueLowest common parent composable
Multiple UI-only fields or operations are becoming complexPlain state holder remembered in Composition
Screen state requires business rules, repositories, or survives the screen lifecycleScreen-level state holder, commonly a ViewModel
Scroll, drawer, pager, or other Compose-owned behaviorComposition/UI-scoped state holder, hoisted only as far as UI logic needs

The goal is not “always use a ViewModel” or “always make every component stateless.” The goal is a single, appropriate owner with predictable data flowing down and events flowing up.

Common mistakes

Duplicating the same value in a child and parent

Two mutable copies drift apart. If a parent passes selectedId, the child should render that value and send an event; it should not also create its own independent selectedId unless it is explicitly managing temporary local UI state.

Hoisting everything to the app root

State should move only as high as necessary. A dialog’s private animation value does not belong beside app navigation state. Over-hoisting makes APIs noisy and expands the recomposition surface without improving the design.

Passing a ViewModel to leaf components

This couples reusable UI to one screen and makes previews and unit-style UI tests harder. Keep the ViewModel at a route or screen boundary; pass values and event callbacks to leaf composables.

Putting business logic in a UI callback

onClick can dispatch an event, but it should not directly transform repository data or perform product rules. Let the screen state holder or domain layer own that work, then render the resulting state.

Forgetting previews for stateless content

Stateless content becomes valuable when it is exercised. Give it realistic sample state and event lambdas in a preview; Compose Preview makes this visual feedback loop fast.

A practical review checklist

  1. Is there exactly one source of truth for each shared UI value?
  2. Is the state owned by the lowest common reader/writer?
  3. Does the component receive state down and send events up?
  4. Is local state truly private and simple enough to remain local?
  5. Are business rules outside leaf composables and ViewModel instances kept at screen boundaries?

State hoisting works especially well with small, focused composables. See how @Composable functions work for the data-in/events-out model, and use the LazyColumn guide when hoisting list state for a feed.

FAQ

Does every composable need to be stateless?

No. A composable can own simple UI element state when no other part of the hierarchy needs it. Hoist only when sharing, UI complexity, or business logic requires another owner.

Is state hoisting the same as using a ViewModel?

No. A ViewModel is one possible state holder, usually for screen state with business logic. State can be hoisted to a parent composable or a plain UI-scoped state holder instead.

Should I use rememberSaveable for every value?

No. Use it for UI state whose restoration materially improves the user experience and that can be saved appropriately. Transient animation or short-lived visual state often does not need restoration.