๐ŸŽจ Jetpack Compose

State Management in Jetpack Compose

Most Compose bugs are state bugs: values that reset, screens that don't update, or recompositions that never stop. This guide maps every kind of state in a Compose app to the right owner and API, with code for each.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Intermediate
On this page
  1. The kinds of state in a screen
  2. Local state with remember
  3. Screen state in a ViewModel
  4. Plain state-holder classes for complex UI logic
  5. Derived state
  6. Turning Compose state into a Flow
  7. Real-world decisions
  8. Common mistakes
  9. Frequently asked questions
  10. Conclusion

In Compose, the UI is derived from state. When it changes, affected composables recompose. That simple idea raises practical questions: where should each piece of state live? Should it survive rotation? Process death? Should it be shared? Answer those correctly and Compose feels effortless.

The kinds of state in a screen

KindExampleOwnerAPI
UI element stateIs this card expanded? Text field focusThe composable, or a plain state-holder classremember / rememberSaveable
Screen UI stateLoaded articles, loading flag, error messageViewModelStateFlow + collectAsStateWithLifecycle
User input to preserveDraft text, selected filtersViewModel with SavedStateHandle, or rememberSaveableSurvives process death
App dataNotes in the database, settingsRepository (Room, DataStore)Flow exposed upward
Where state lives in a Compose app From bottom to top: repositories own app data as Flows; the ViewModel combines them into a screen StateFlow; the screen composable collects it; small UI element state such as expanded flags is remembered inside components. Repositories: app data as Flow (Room, DataStore, network) ViewModel: StateFlow<ScreenUiState> (+ SavedStateHandle) Screen composable: collects state components:remember { expanded }
State lives at the lowest level that needs it, and is hoisted only as high as its users require.

Local state with remember

ExpandableCard.ktKotlin
@Composable
fun ExpandableCard(title: String, body: String, modifier: Modifier = Modifier) {
    var expanded by rememberSaveable { mutableStateOf(false) }   // survives rotation
    Card(modifier.fillMaxWidth().clickable { expanded = !expanded }) {
        Column(Modifier.padding(16.dp).animateContentSize()) {
            Text(title, style = MaterialTheme.typography.titleMedium)
            if (expanded) Text(body, Modifier.padding(top = 8.dp))
        }
    }
}

mutableStateOf creates an observable value. remember keeps it across recompositions. Without remember, every recomposition would create a fresh false.

Screen state in a ViewModel

InboxViewModel.ktKotlin
data class InboxUiState(
    val messages: List<Message> = emptyList(),
    val filter: Filter = Filter.ALL,
    val isRefreshing: Boolean = false,
)

class InboxViewModel(repo: MessageRepository, private val saved: SavedStateHandle) : ViewModel() {
    private val filter = saved.getStateFlow("filter", Filter.ALL)   // restored after process death
    private val refreshing = MutableStateFlow(false)

    val uiState: StateFlow<InboxUiState> =
        combine(repo.observeMessages(), filter, refreshing) { msgs, f, r ->
            InboxUiState(msgs.filter(f::matches), f, r)
        }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), InboxUiState())

    fun setFilter(f: Filter) { saved["filter"] = f }
}

@Composable
fun InboxRoute(viewModel: InboxViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    InboxScreen(state, onFilterChange = viewModel::setFilter)
}

Filter needs to be storable in a Bundle, for example as an enum, for SavedStateHandle to save it.

Plain state-holder classes for complex UI logic

When a component's UI logic grows (several related state values and rules), move it into a plain class remembered in composition. It's simpler than a ViewModel and still testable.

SearchBarState.ktKotlin
@Stable
class SearchBarState(initialQuery: String) {
    var query by mutableStateOf(initialQuery)
        private set
    var active by mutableStateOf(false)
        private set
    val showClear: Boolean get() = query.isNotEmpty()

    fun onQueryChange(q: String) { query = q.take(100) }
    fun clear() { query = "" }
    fun activate(value: Boolean) { active = value }
}

@Composable
fun rememberSearchBarState(initialQuery: String = ""): SearchBarState =
    remember { SearchBarState(initialQuery) }

Compose's own LazyListState, ScrollState and DrawerState follow exactly this pattern.

Derived state

derivedStateOfKotlin
val listState = rememberLazyListState()
// Recompose readers only when the boolean flips, not on every scroll pixel.
val showJumpToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 3 } }

Use derivedStateOf when the input changes more often than the result. For simple derivations from parameters, plain computation, or remember(key) for expensive work, is enough.

Turning Compose state into a Flow

snapshotFlowKotlin
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .distinctUntilChanged()
        .collect { index -> analytics.log("scrolled_to", index) }
}

Real-world decisions

Checkout form

Field values and validation live in the ViewModel, with in-progress input in SavedStateHandle so process death doesn't wipe the form.

Photo carousel

The current page index is UI element state in rememberPagerState, hoisted only if a parent needs to show "3 of 10".

Settings toggles

App data in DataStore, exposed as a Flow and mapped to UI state in the ViewModel. Toggles call ViewModel functions.

Common mistakes

  • mutableStateOf without remember, so state resets.
  • Using collectAsState() where collectAsStateWithLifecycle() is better on Android, since it stops collecting in the background.
  • Duplicating state, for example a copy of ViewModel data in a local remember that goes out of sync.
  • Mutable collections inside state (mutableListOf in a mutableStateOf). Changes inside the list aren't observed. Use immutable lists with copy, or mutableStateListOf.
  • Writing state during composition, which causes infinite recomposition loops.

Frequently asked questions

Do I need a ViewModel for every screen?

For screens with business logic or data loading, yes. Purely presentational screens can rely on local state and parameters.

StateFlow or Compose State in the ViewModel?

Both work. StateFlow keeps ViewModels independent of Compose and integrates with Flow operators, so it's the most common choice. Compose mutableStateOf in ViewModels is also supported.

How do I share state between two screens?

Hoist it to a common owner, such as a ViewModel scoped to a navigation graph or the Activity, or move it into the data layer where both screens observe it.

Conclusion

Good Compose state management is about ownership: UI element state in remember/rememberSaveable or state-holder classes, screen state in a ViewModel's StateFlow, preserved input in SavedStateHandle, and app data in repositories. Keep a single source of truth for each value and let the UI derive from it.

Next step: get the survival rules exactly right with remember vs rememberSaveable.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Jetpack Compose clicks once you build real screens with real state. Practise modern Android UI development on projects that go beyond toy examples.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp