Skip to content
All posts
July 7, 20265 min read

Jetpack Compose State Management: From Simple to Complex

How I think about state in Compose as screens grow — from a single remembered value to hoisted state, ViewModel-backed StateFlow, and the rules that keep recomposition cheap and bugs rare.

Jetpack ComposeState ManagementAndroidStateFlowKotlin
Share:

State is the part of Compose that looks easy in the docs and gets messy in real apps. A counter is trivial. A screen with a form, async loading, validation, and a snackbar is where most state bugs live. After building this same pattern across a couple dozen apps, I've settled on a progression: start as simple as the screen allows, and only climb the ladder when the screen forces you to.

Level 1: Local State With remember

If a piece of state never leaves a single composable, keep it there.

kotlin
@Composable
fun PasswordField() {
    var visible by remember { mutableStateOf(false) }
    // toggle visible; nobody outside this composable cares
}

code
remember
survives recomposition;
code
rememberSaveable
survives configuration changes and process death for simple types. The mistake here is reaching for a ViewModel to hold a boolean that only one widget uses. That's ceremony with no payoff.

Level 2: Hoist State When Someone Else Needs It

The moment a parent needs to read or control that state, hoist it. A composable that takes its state as a parameter and emits changes as a callback is testable, previewable, and reusable.

kotlin
@Composable
fun SearchBar(
    query: String,
    onQueryChange: (String) -> Unit,
) {
    TextField(value = query, onValueChange = onQueryChange)
}

The rule of thumb: state goes to the lowest common ancestor that needs it, and no higher. Hoist too far and every keystroke recomposes half the tree. Hoist too little and you can't share it.

Level 3: ViewModel + StateFlow for Screen State

Once state must survive navigation, involve business logic, or come from a repository, it belongs in a ViewModel exposed as

code
StateFlow
. I never use
code
LiveData
in new code —
code
StateFlow
composes better and works the same in tests and on other platforms.

kotlin
class ProfileViewModel(repo: UserRepository) : ViewModel() {
    private val _state = MutableStateFlow(ProfileUiState())
    val state: StateFlow<ProfileUiState> = _state.asStateFlow()

    fun onNameChange(name: String) {
        _state.update { it.copy(name = name) }
    }
}

In the UI, collect it lifecycle-aware:

kotlin
val state by viewModel.state.collectAsStateWithLifecycle()

code
collectAsStateWithLifecycle
stops collection when the screen is in the background, which
code
collectAsState
does not. That difference has fixed real battery and crash reports for me.

Model UI State as One Object

A screen with five

code
mutableStateOf
fields will eventually hold a contradictory combination — loading and error at the same time. I model the whole screen as a single immutable data class, and where states are mutually exclusive, a sealed interface:

kotlin
sealed interface ProfileUiState {
    data object Loading : ProfileUiState
    data class Error(val message: String) : ProfileUiState
    data class Ready(val name: String, val email: String) : ProfileUiState
}

Now the UI is a

code
when
over impossible-to-misrepresent states. The compiler enforces that I handle every branch.

Keep Recomposition Cheap

Complex state is fine; expensive recomposition is not. Two habits prevent most jank:

  • Use
    code
    derivedStateOf
    for values computed from other state so they only recompute when inputs actually change.
  • Keep state objects stable (immutable data classes,
    code
    kotlinx.collections.immutable
    lists) so Compose can skip composables whose inputs didn't change.
kotlin
val canSubmit by remember {
    derivedStateOf { state.name.isNotBlank() && state.email.contains("@") }
}

A Mental Model That Scales

The reason state management feels overwhelming is that people treat it as one big problem instead of a ladder of small ones. Every screen sits on exactly one rung, and the rung is decided by a single question: who else needs to see or change this value? A toggle that only one widget reads stays local. A search query a parent filters on gets hoisted. A user profile that must survive rotation, come from the network, and feed three screens lives in a ViewModel. You don't choose the rung by taste; the screen's requirements choose it for you, and your job is just to read them honestly.

The failure mode I see most often is climbing too high too early. A developer reads that ViewModels are the "right" way and routes a checkbox through one, and now a trivial interaction involves a flow, a reducer, and three files. The opposite failure is climbing too late: cramming network state and validation into scattered

code
mutableStateOf
fields until two of them disagree and the screen renders a state that should be impossible. Both come from ignoring the question and reaching for a habit.

What keeps this sane over dozens of apps is that the ladder is the same every time. I never reinvent how a screen holds state; I just identify the rung and apply the matching pattern. New screens become boring, and boring is exactly what you want from infrastructure. The interesting part of the app should be the feature, not the plumbing that holds its state. When state management is a decision you make in ten seconds instead of an architecture debate, you ship faster and you debug less, because the structure tells you where every value lives before you go looking.

Key Takeaways

  • Start with
    code
    remember
    /
    code
    rememberSaveable
    for state that lives in one composable; don't reach for a ViewModel by reflex.
  • Hoist state to the lowest common ancestor that needs it — no higher, no lower.
  • Use a ViewModel exposing
    code
    StateFlow
    once state must survive navigation or involves business logic; skip
    code
    LiveData
    in new code.
  • Collect with
    code
    collectAsStateWithLifecycle
    so background screens stop doing work.
  • Model each screen as one immutable state object, and use a sealed interface for mutually exclusive states so the compiler keeps them honest.
  • Protect recomposition with
    code
    derivedStateOf
    and stable, immutable state types.
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this