State Management in Jetpack Compose

01The State Model

Compose is a reactive UI framework. Composable functions are re-invoked (recomposed) whenever their observed state changes. Understanding recomposition boundaries is essential before touching any state API.

State → Composition

State is read inside a composable. When that state object changes, only the scopes that read it are invalidated and scheduled for recomposition — not the entire tree.

State
──▶ compose ──▶
UI
──▶ event ──▶
Logic
──▶ mutate ──▶
State
Unidirectional Data Flow — the canonical Compose pattern

Snapshot System

Compose state is backed by Snapshot — a transaction-like mechanism that records reads and writes. MutableState objects participate in this system automatically; plain Kotlin fields do not trigger recomposition.

Note Mutating regular Kotlin variables inside a composable will not schedule recomposition. Always use Compose state holders or observable types that integrate with the Snapshot system.

02remember & mutableStateOf

remember caches a value across recompositions. Without it, every recomposition re-executes the initializer and discards the previous value. Wrapping a MutableState object in remember is the baseline local state primitive.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("Clicked $count times")
    }
}

Delegation syntax

The by delegate (via getValue/setValue extensions on State) unwraps the value, giving you direct read/write access. The alternative is val state = remember { mutableStateOf(0) }, accessed as state.value.

remember keys

Pass keys to remember to invalidate and re-run the initializer when they change.

val filtered = remember(query) {
    items.filter { it.contains(query, ignoreCase = true) }
}
// re-runs whenever `query` changes
Warning remember state is scoped to the composition. If the composable leaves the tree (e.g., navigation, conditional rendering), the value is lost. Use rememberSaveable or a ViewModel for persistence.

Other observable types

Type Use case Notes
mutableStateOf() Single value Most common; any type
mutableStateListOf() Observable list Structural changes trigger recomposition; index-level reads are fine-grained
mutableStateMapOf() Observable map Same granularity as StateList
mutableIntStateOf() etc. Primitives Avoids boxing; prefer over mutableStateOf<Int> in hot paths

03State Hoisting

A composable is stateless when it receives state as a parameter and emits events via lambdas instead of owning state internally. This makes it reusable, testable, and previewable.

// Stateful — owns its own state, cannot be tested or reused cleanly
@Composable
fun EmailFieldStateful() {
    var text by remember { mutableStateOf("") }
    TextField(value = text, onValueChange = { text = it })
}

// Stateless — state and events hoisted to caller
@Composable
fun EmailField(
    value: String,
    onValueChange: (String) -> Unit
) {
    TextField(value = value, onValueChange = onValueChange)
}

Hoist state to the lowest common ancestor of all composables that need to read or mutate it. Over-hoisting (lifting state to the root unconditionally) increases recomposition scope unnecessarily.

State holders

When hoisting produces a large parameter list or complex state logic, extract a plain class (state holder) to encapsulate it. State holders are created with remember, keeping them local to the composition.

class LoginState {
    var email   by mutableStateOf("")
    var password by mutableStateOf("")
    val isValid  get() = email.isNotBlank() && password.length >= 8
}

@Composable
fun rememberLoginState() = remember { LoginState() }

@Composable
fun LoginScreen() {
    val state = rememberLoginState()
    LoginForm(state)
}

04rememberSaveable

rememberSaveable survives configuration changes (rotation, font scale) and process death by saving to the SavedStateHandle mechanism under the hood. It serializes via Bundle by default, so values must be either primitives, Parcelable, or Serializable.

var query by rememberSaveable { mutableStateOf("") }
// Survives rotation. Cleared on back navigation (as expected).

Custom savers

For types that aren't Parcelable, implement a Saver and pass it via the stateSaver parameter.

val LatLngSaver = Saver<LatLng, List<Double>>(
    save    = { listOf(it.latitude, it.longitude) },
    restore = { LatLng(it[0], it[1]) }
)

var position by rememberSaveable(stateSaver = LatLngSaver) {
    mutableStateOf(LatLng(0.0, 0.0))
}
Note rememberSaveable is appropriate for UI state (scroll position, text field contents, dialog visibility). Application data and business state belong in a ViewModel backed by SavedStateHandle.

05ViewModel & StateFlow

ViewModels survive configuration changes and own screen-level state. Expose state as a StateFlow (or State<T>) and convert it to Compose state with collectAsStateWithLifecycle.

class SearchViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(SearchUiState())
    val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()

    fun onQueryChange(query: String) {
        _uiState.update { it.copy(query = query, isLoading = true) }
        viewModelScope.launch {
            val results = repository.search(query)
            _uiState.update { it.copy(results = results, isLoading = false) }
        }
    }
}

@Composable
fun SearchScreen(vm: SearchViewModel = viewModel()) {
    val state by vm.uiState.collectAsStateWithLifecycle()
    SearchContent(state = state, onQueryChange = vm::onQueryChange)
}

collectAsStateWithLifecycle vs collectAsState

collectAsStateWithLifecycle (from androidx.lifecycle:lifecycle-runtime-compose) automatically stops collection when the lifecycle drops below STARTED, avoiding wasted work in the background. Prefer it over collectAsState for UI-bound flows.

Single UI state class

Model screen state as a single data class. This prevents state inconsistencies that arise from managing multiple independent StateFlows that must always be coherent with each other.

// Prefer this
data class SearchUiState(
    val query: String      = "",
    val results: List<Item> = emptyList(),
    val isLoading: Boolean  = false,
    val error: String?      = null
)

// Avoid: separate flows that must stay in sync
val isLoading = MutableStateFlow(false)
val results   = MutableStateFlow(emptyList<Item>())

One-time events

Navigation and toasts are events, not state — they shouldn't be in the UI state class. Use a Channel exposed as a Flow, or an event wrapper sealed class in the state with a consumed flag.

private val _events = Channel<SearchEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()

// In the composable
LaunchedEffect(Unit) {
    vm.events.collect { event ->
        when (event) {
            is SearchEvent.NavigateToDetail -> navController.navigate(event.id)
        }
    }
}

06derivedStateOf

derivedStateOf creates a state object whose value is computed from other state. Recomposition is only triggered when the result changes, not every time the source state changes. It is the Compose equivalent of a distinctUntilChanged map.

val listState = rememberLazyListState()

// Without derivedStateOf: recomposes on every scroll pixel
val showFab = listState.firstVisibleItemIndex > 0

// With derivedStateOf: recomposes only when the boolean flips
val showFab by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

AnimatedVisibility(visible = showFab) { FloatingActionButton(...) }
Tip Always wrap derivedStateOf in remember. Without it, a new derived state object is allocated on every recomposition, defeating the purpose.

Use derivedStateOf when source state changes at high frequency but the downstream UI only needs to react to a subset of those changes. Threshold detection, validation flags, and computed summaries are common cases.


07Side Effects

Side effects must run in a controlled scope tied to the composition lifecycle. Never launch coroutines or register callbacks directly in the body of a composable function — those run on every recomposition.

API Trigger Cleanup Use for
LaunchedEffect Key change / enter composition Cancels coroutine on key change or leave Async work, one-time operations
DisposableEffect Key change / enter composition onDispose block on key change or leave Registering/unregistering listeners
SideEffect Every successful recomposition None Syncing Compose state to non-Compose objects
produceState Enter composition Cancels on leave Converting non-Compose state to Compose state
rememberCoroutineScope On demand (event-driven) Cancels scope on leave Launching coroutines from callbacks

LaunchedEffect

LaunchedEffect(userId) {
    // Runs when `userId` changes. Previous coroutine is cancelled first.
    val profile = repository.fetchProfile(userId)
    onProfileLoaded(profile)
}

DisposableEffect

DisposableEffect(lifecycle) {
    val observer = LifecycleEventObserver { _, event ->
        if (event == Lifecycle.Event.ON_RESUME) onResume()
    }
    lifecycle.addObserver(observer)
    onDispose { lifecycle.removeObserver(observer) }
}

rememberCoroutineScope

Use when you need to launch a coroutine from a non-composable callback (e.g., a button click). The scope is tied to the composition and cancelled when the composable leaves.

val scope = rememberCoroutineScope()
val snackbarState = remember { SnackbarHostState() }

Button(onClick = {
    scope.launch { snackbarState.showSnackbar("Saved") }
}) { Text("Save") }
Danger Avoid using GlobalScope or manually creating coroutine scopes inside composables. They will outlive the composition and leak resources.

08Patterns & Pitfalls

Recomposition scope minimization

Compose recomposes the smallest scope that reads changed state. Lambda bodies, content lambdas in Box/Column/LazyColumn, and item blocks each form their own scope. Avoid reading high-frequency state at a wide scope.

// Wide scope: the whole composable recomposes on every scroll offset change
@Composable
fun Header(listState: LazyListState) {
    val alpha = (listState.firstVisibleItemScrollOffset / 300f).coerceIn(0f, 1f)
    Box(modifier = Modifier.alpha(alpha)) { ... }
}

// Narrow scope: read offset inside the Modifier lambda (graphicsLayer)
@Composable
fun Header(listState: LazyListState) {
    Box(modifier = Modifier.graphicsLayer {
        alpha = (listState.firstVisibleItemScrollOffset / 300f).coerceIn(0f, 1f)
    }) { ... }
    // graphicsLayer lambda runs in the draw phase, not recomposition
}

Stability

Compose's compiler plugin infers whether types are stable. Unstable parameter types force recomposition even when values are structurally equal. Mark classes with @Stable or @Immutable when appropriate, or use the Compose compiler metrics to identify unstable parameters.

Annotation Contract
@Immutable All public properties are val and of immutable types. Compose trusts equality without re-checking.
@Stable Public properties may be var but changes notify Compose. Used for state holders with observable fields.

Common pitfalls

CompositionLocal

Use CompositionLocal for ambient data that many composables in a subtree need without explicit parameter passing (themes, locale, analytics context). It is not a general state management solution; overuse leads to implicit, hard-to-trace data flow.

val LocalAnalytics = staticCompositionLocalOf<Analytics> {
    error("No Analytics provided")
}

// Provide at a screen or nav graph level
CompositionLocalProvider(LocalAnalytics provides analyticsImpl) {
    ScreenContent()
}

// Consume anywhere below
val analytics = LocalAnalytics.current
Tip Prefer staticCompositionLocalOf when the value rarely changes. It avoids the overhead of tracking reads across the subtree. Use compositionLocalOf only when the value changes frequently and you want fine-grained recomposition.