Compose

Understanding State in Jetpack Compose: Remember, State Hoisting, and Beyond

Ahmad Najar Ahmad Najar 2 min read

State in Compose Isn't Magic — It's Just a Recomposition Trigger

The hardest mental shift moving into Jetpack Compose isn't the syntax — it's understanding that state is what drives the UI, not the other way around. In the View system, you mutate a widget directly (textView.text = "..."). In Compose, you mutate state, and the framework decides what to redraw.

The building block: remember + mutableStateOf

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Clicked $count times")
    }
}

remember survives recomposition but not configuration changes unless paired with rememberSaveable. This trips up a lot of View-system veterans: a rotation will silently reset a remember-only counter.

State hoisting: the pattern that actually matters

A composable that owns its own state is hard to test and hard to reuse. Hoisting means the composable takes state as a parameter and reports changes via a callback:

@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
    Button(onClick = onIncrement) { Text("Clicked $count times") }
}

Now the caller decides where the state actually lives — a parent composable, a ViewModel, or a saved-state handle. This is the same principle as "lifting state up" in React, and once it clicks, most Compose architecture questions answer themselves.

Where the ViewModel fits in

Local, ephemeral UI state (is a dropdown expanded, what's the scroll position) belongs in remember. Anything that needs to survive process death, comes from a repository, or is shared across screens belongs in a ViewModel exposing a StateFlow:

class ProfileViewModel(private val repo: ProfileRepository) : ViewModel() {
    val uiState: StateFlow = repo.observeProfile()
        .map { ProfileUiState.Success(it) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ProfileUiState.Loading)
}

The rule of thumb: if losing the value on rotation would annoy a user, it doesn't belong in a bare remember.

Common mistakes

  • Reading state too high in the tree — if a large composable reads a frequently-changing value, the whole subtree recomposes. Push state reads down into the smallest composable that needs them.
  • Forgetting derivedStateOf — for computed values from other state (e.g. "is the list scrolled past item 3"), wrap it so recomposition only happens when the derived value actually changes, not on every raw update.
  • Mutating state outside the snapshot system — plain var fields won't trigger recomposition at all; it has to be a Compose State object.

Get comfortable with these four ideas — remember, hoisting, StateFlow-backed ViewModels, and derivedStateOf — and most "why isn't my UI updating" bugs stop happening in the first place.

Compose