count is a plain local variable: it is reset to 0 on every recomposition, and writing it tells Compose nothing. The fix needs both halves (remember to keep the value and mutableStateOf to make the write observable).
**remember**: parks the value in the composition, so the next run of the function gets it back instead of starting from 0.
**mutableStateOf**: Compose tracks which state objects a composable read; writing one of them schedules that composable to recompose. A plain var is invisible to it.
Either alone is still broken: remember { 0 } keeps the number but never redraws; mutableStateOf(0) without remember redraws, but builds a fresh state holding 0 each time.
The working line is var count by remember { mutableIntStateOf(0) }. If a log in onClick prints but the UI stays put, the problem is observability, not the click.
AndroidMiddle
Compose State
remember, mutableStateOf, derivedStateOf, state hoisting, ViewModel + StateFlow
Questions closed
0 / 20
- 0Review
- 7Asked often
Questions
mutableStateOf(value) creates an observable MutableState<T>: when a composable reads .value, Compose records the read, and when someone writes a new value, every composable that read it is scheduled to recompose.
Read = subscribe: there is no explicit listener. Reading the state during composition is what links it to that composable.
Write = invalidate: only the readers recompose, not the whole screen. Writing a value equal to the current one (by equals) does nothing.
**Always wrap in remember inside a composable, otherwise each recomposition creates a new state.
Syntax**: by lets you skip .value; for primitives prefer mutableIntStateOf and friends, which avoid boxing.
remember keeps a value across recompositions only; rememberSaveable also writes it into saved instance state, so it survives a configuration change and process death.
**remember: lost when the composable leaves the composition, when the Activity is recreated (rotation, theme, resize) and when the process dies.
rememberSaveable**: restored after rotation and process death (and where a navigation back stack or lazy list re-creates the composable). It does NOT survive being removed from the composition by a plain if. The value must fit in a Bundle (primitives, String, Parcelable) or come with a Saver; listSaver and mapSaver cover most custom types.
Keys: remember(userId) { ... } throws the old value away and recomputes when the key changes.
What goes where: remember for objects that are cheap to rebuild (a formatter, an animation state); rememberSaveable for what the user typed or picked: a draft, a selected tab, an expanded flag.
Neither replaces a ViewModel. Data loaded from a repository belongs there, and the saved-state Bundle should stay small.
State hoisting moves state out of a composable into its caller: the composable becomes stateless, receiving the current value and an onValueChange callback, while a stateful one owns its state with remember inside.
Stateless: reusable, previewable with plain values, testable without setup, and the caller decides where the state comes from. Material's TextField, Switch and Checkbox all work this way.
Stateful: convenient to drop in, but the caller cannot read, reset or control the state.
Common pattern: offer both: a stateless NameField(name, onNameChange) and, if useful, a thin stateful wrapper over it.
To decide where state lives, hoist to the lowest common ancestor of everything that reads or writes it, and no higher:
Used by one composable only (an expanded flag, a scroll position used only here): keep it local.
Shared by siblings: hoist to their parent.
Business data or anything that must survive navigation: the screen's ViewModel.
Hoisting too high has a cost too: every state change then recomposes a bigger part of the tree.
remember runs its lambda only the first time that call site is composed and returns the stored value afterwards, so a parameter that changes later never reaches the state it once seeded.
rememberSaveable behaves the same, so switching remember functions does not help. The ways out, best first:
Hoist it: if the caller has the value, the caller is the source of truth. Take name and onNameChange and keep no local state at all.
Key the remember: remember(initialName) { mutableStateOf(initialName) } discards the state and recreates it whenever the key changes by equals.
Sync in an effect: LaunchedEffect(initialName) { name = initialName } when local edits should be kept and only a real upstream change overwrites them.
The mirror-image bug: keying on an object that is a new instance on every emission, such as remember(user), wipes what the person is typing on every refresh. Key on an id, not on the whole object.
The ViewModel exposes a StateFlow<UiState>, and the screen reads it with collectAsStateWithLifecycle(), which collects only while the lifecycle is at least STARTED.
**collectAsStateWithLifecycle()**: from lifecycle-runtime-compose. Stops collecting when the app goes to the background, so the upstream (location, database, socket) stops too. The default on Android.
**collectAsState(): collects for as long as the composable is in the composition, which includes a stopped Activity. Fine for a flow that is cheap to keep alive or in code without a lifecycle owner.
produceState(initial, key) { value = ... }**: turns any suspend call or callback into State inside the composition. For a one-off suspend call with no ViewModel.
The usual shape:
The ViewModel builds state with stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial), so it survives a rotation but stops when the screen is gone.
A route composable collects state and passes plain values and lambdas to a stateless screen composable, which is the one you preview and test.
Snapshot state tracks the reference it holds, not what happens inside that object: list.add(x) keeps the same instance, so no write to the state ever happens and no reader is invalidated.
The same applies to StateFlow: _state.value.items.add(item) edits the list behind the value without emitting anything.
Assign a new list: keep the type a read-only List and write items = items + item. In a ViewModel: _state.update { it.copy(items = it.items + item) }. update is a compare-and-set loop, so two coroutines cannot overwrite each other's change.
**mutableStateListOf() / mutableStateMapOf()**: snapshot-backed collections whose add, remove and set really notify readers. Good inside a composable or a plain state holder.
Do not mix the two: a SnapshotStateList inside a StateFlow value is mutated in place, so the flow never emits and flow collectors (stateIn, combine) never see the change.
Equality trap: copy(items = items) after an in-place edit produces an object equal to the old one, so a StateFlow still drops it.
PersistentList from kotlinx.collections.immutable makes the intent explicit and is treated as stable by the Compose compiler.
derivedStateOf { ... } creates a state computed from other state that notifies its readers only when the result changes, not on every change of the inputs.
When it helps: the input changes much more often than the output. The classic case: listState.firstVisibleItemIndex changes on almost every scroll frame, but showScrollToTop flips rarely. Readers of the Boolean recompose only on the flip.
**Always inside remember**: remember { derivedStateOf { ... } }. Without remember a new derived state is built on every recomposition.
remember without keys builds the derivedStateOf once, and its lambda keeps the products and maxPrice of the first composition. Only query is a State, so typing re-filters the list, but a new price limit or fresh data from the server is ignored.
Why: derivedStateOf recalculates only when a snapshot State read inside it changes. Plain parameters are captured values, not states: the parent passes maxPrice = 500, ProductList recomposes, and remember hands back the old derived state with the old closure.
The fix: key remember on every plain value the calculation uses: remember(products, maxPrice) is the minimum. Better, drop the wrapper: here the result changes whenever any input changes, so remember(products, maxPrice, query) { ... } is enough. derivedStateOf pays off only when the output changes less often than its inputs (a Boolean from a scroll offset, say).
The rule: State read inside the block is tracked automatically; parameters and other non-State values go into the remember keys, the same rule as for LaunchedEffect keys.
How to catch it: in review, flag any remember { derivedStateOf { } } that reads a function parameter. A UI test that changes the parameter through a mutableStateOf in setContent and asserts the new row appears fails on the buggy version.
A side effect is anything that changes the world outside the composable; because a composable body can re-run at any time, effects go through effect APIs that start, restart and clean up in step with the composition.
The effect APIs
**LaunchedEffect(key) { }: launches a coroutine when it enters the composition, cancels and relaunches it when a key changes, cancels it on exit. For loading data tied to an argument, a timer, collecting a flow of one-off events.
DisposableEffect(key) { onDispose { } }**: for anything that must be unregistered: listeners, sensors, LifecycleObservers. For lifecycle callbacks, LifecycleStartEffect and LifecycleResumeEffect are ready-made versions.
**SideEffect { }: runs after every successful recomposition. For pushing Compose state into a non-Compose object, such as the current screen into an analytics client.
produceState(initial, key) { }**: a LaunchedEffect plus a State in one call. For work started by a click use rememberCoroutineScope() instead.
Keys decide everything
LaunchedEffect(Unit) runs once per entry into the composition. If the block uses a parameter, either key on it or read it through rememberUpdatedState.
Keying on an object recreated on every recomposition restarts the effect every time.
Never launch a coroutine or call a repository directly from the composable body: it would run on every recomposition.
LaunchedEffect is a composable and onClick is a plain lambda, so the compiler reports @Composable invocations can only happen from the context of a @Composable function. Work started by a user event needs rememberCoroutineScope() and scope.launch { }.
**LaunchedEffect: for work driven by the composition itself: the screen appears, a key changes. It is declared in the composable body.
rememberCoroutineScope()**: returns a scope bound to the composition. Use it inside callbacks such as onClick; its coroutines are cancelled when the composable leaves.
Good fits: UI work that should die with the screen: snackbarHostState.showSnackbar, listState.animateScrollToItem, sheetState.hide().
Bad fit: saving data or a network call. That scope dies with the screen, so the request is cancelled if the user leaves. Call the ViewModel and let viewModelScope or a repository own the work.
A common clumsy workaround is a var trigger flag watched by LaunchedEffect(trigger); it works but needs a reset and an extra recomposition, while the scope runs the code directly.
LaunchedEffect(Unit) starts once and its coroutine captures the onTimeout from that first composition; later recompositions pass a new lambda, but the running coroutine still holds the old one.
Keying on the lambda is wrong here: LaunchedEffect(onTimeout) would restart the effect, and the three-second timer would start again on every change.
**rememberUpdatedState(onTimeout)**: returns a State that is updated with the latest value on every recomposition. The effect keeps running and reads currentOnTimeout only at the moment it needs it, so it always calls the newest lambda.
When to use it: a long-lived effect (a timer, a listener in DisposableEffect, a pointerInput block) that must not restart but must see current values.
When not to: if a changed value should restart the work, such as a new userId for a load, make it a key instead.
The same stale capture happens with any value read inside the effect, not only with lambdas.
snapshotFlow { } turns reads of Compose state into a cold Flow: it runs the block, remembers which state it read, and emits a new result whenever one of those states changes.
When you need it: you want Flow operators on Compose state: debounce, filter, flatMapLatest, collectLatest. Typical cases: logging a scroll position, saving a draft after the user stops typing, searching on a TextFieldState.
Where it runs: inside a LaunchedEffect, or in a ViewModel with stateIn(viewModelScope, ...).
Equal results are skipped: consecutive equal values are not emitted, so distinctUntilChanged() right after it is redundant.
Only snapshot state is tracked: a plain property read in the block never causes a new emission.
The reverse direction, Flow to Compose state, is collectAsStateWithLifecycle().
A CompositionLocal hands a value implicitly to everything below its provider, which is right for ambient context such as theme or density and wrong for the data a screen is about.
How it works
compositionLocalOf { default }: reads are tracked, so a new provided value recomposes only the composables that read it. For values that change while the screen is shown.
staticCompositionLocalOf { default }: reads are not tracked; a change recomposes the whole provided subtree. Cheaper to read, for values that almost never change.
CompositionLocalProvider(LocalX provides value) { } scopes a value to a subtree, and LocalX.current reads the nearest one.
Right and wrong uses
Right: cross-cutting values most of the tree needs: LocalContext, LocalDensity, LocalContentColor, your own spacing tokens or an image loader.
Wrong: a user, a cart or a ViewModel. The composable's inputs become invisible, every preview and test needs a provider, and a missing provider silently gives you the default instead of a compile error.
It is not a dependency injection container: a dependency belongs in the constructor of the class that uses it.
If there is no sensible default, make the default throw with error(...) so a missing provider fails loudly.
A plain state holder is for UI logic (state that only the UI cares about and that may die with the composable); a ViewModel is for business logic and screen data that must survive configuration changes and reach the data layer.
Plain state holder: an ordinary class with mutableStateOf fields and the logic over them, created by a rememberCheckoutFormState() function. It lives as long as the composable and can hold a rememberCoroutineScope() or a LazyListState. The framework's own holders are the model: LazyListState, SnackbarHostState, DrawerState.
ViewModel: loaded data, what the user submits, anything that needs viewModelScope, a repository or SavedStateHandle.
How to tell: whether the FAB hides on scroll or an input looks like an email is UI logic. Business logic decides whether the order can be placed or what the discount is.
Signs it is time to extract: several remember values that always change together, or a composable body doing work that is not emitting UI.
A plain holder is lost on rotation unless the factory uses rememberSaveable with a Saver.
The row has two sources of truth: a local copy that flips on tap and the ViewModel value, which is re-applied with the old false before the write lands, so the star flips back until the server answer arrives.
IconToggleButton, Switch and Checkbox draw exactly the checked value you pass, so a flicker means two sources of truth disagree:
The tap writes the local copy, and the star fills.
The ViewModel emits a new item (any other field changed) that still has isFavorite = false; the remember(item) key changes and resets the copy, and the star empties.
The response arrives, the ViewModel flips, and the star fills again.
One owner, optimistic
The ViewModel updates its own state immediately, then sends the request. The UI remembers nothing: checked = item.isFavorite.
Keep the previous value and roll back if the call fails, with a visible error such as a snackbar.
Track ids in flight, so a second tap does not fire a second request.
With an offline-first repository (write to Room, then sync), the database is the single source of truth and its flow re-emits on its own.
Syncing the local copy with LaunchedEffect(item.isFavorite) keeps the race and adds a frame of lag.
StateFlow keeps the ViewModel free of Compose and gives you Flow operators; mutableStateOf is simpler to read in Compose and recomposes per state object, but ties the ViewModel to the Compose runtime.
What Compose state gives you
The screen reads vm.uiState directly: no collector, no initial value, no lifecycle question.
Read tracking per state object. With separate query and results states, a composable that reads only query does not recompose when results arrive. One StateFlow<UiState> notifies every reader of the whole object.
What StateFlow gives you
Operators: combine, flatMapLatest, debounce, stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial) to build state from repository flows. With Compose state you need snapshotFlow to get back into Flow.
No Compose runtime dependency in the ViewModel, which keeps it shareable with View-based screens and plain Kotlin modules.
Plain coroutine tests with runTest and Turbine. Compose state is testable too, but anything observing it through snapshotFlow or an apply observer only reacts after Snapshot.sendApplyNotifications() (the Compose test rule does it for you, plain unit tests do not).
What is the same
Both skip equal values: MutableStateFlow by equals, mutableStateOf by structuralEqualityPolicy() by default.
Both need immutable values; in-place mutation breaks either one.
Pick one per codebase. StateFlow is the more common default; Compose state fits a Compose-only app with large screens where fine-grained reads matter.
The value/onValueChange loop must update synchronously; any async hop lets the field render text older than what was typed. TextFieldState removes the loop by letting the field own the edit buffer.
Where it breaks
Anything asynchronous between the two halves (debounce, flowOn, a LaunchedEffect, a suspend validation) lets the next keystroke arrive before the previous value returns, so the field jumps back.
Filtering in onValueChange (removing non-digits, upper-casing) gives the keyboard a value it did not expect, and the cursor jumps to the end.
Returning a different or late String than the keyboard just produced discards the selection and composing region.
What fixes it
With the value-based API, keep the loop synchronous: write plain snapshot state directly in onValueChange, never through a flow with operators.
Better, use the state-based field: BasicTextField(state = ...) or the Material 3 TextField(state = ...) overload. The field edits its TextFieldState directly, so there is no round trip.
InputTransformation filters keystrokes as they are applied, OutputTransformation formats what is shown (spaces in a card number) without changing the stored text.
The ViewModel can own the TextFieldState and observe it with snapshotFlow { state.text } to debounce a search or validate.
Save what the user typed or picked: UI-only fields in rememberSaveable, what the ViewModel needs in SavedStateHandle; everything loaded from the data layer is reloaded by id.
What to save
The user's unsent input and pointers: the draft, the query, the selected id, the tab. Never the loaded data; reload it by id.
handle.getStateFlow(key, default) gives an observable entry; navigation arguments arrive in the same handle.
The Bundle goes through a Binder transaction with about a 1 MB limit, so a large one ends in TransactionTooLargeException.
How to test
In unit tests, build the ViewModel with SavedStateHandle(mapOf(...)) and check it restores from the map.
For composables, StateRestorationTester simulates recreation and checks rememberSaveable.
Compose state lives in snapshots (isolated, versioned views of all state objects), which give every reader a consistent picture and make a group of writes behave like a transaction.
The mechanics
A MutableState holds a chain of state records, each tagged with the snapshot that wrote it. A read returns the newest record visible to the reader's snapshot.
Reads are observed. Composition, layout and draw run with a read observer attached, which is how Compose knows which scopes to invalidate. Recomposition is driven by that read set, not by diffing a tree.
Writes are isolated. A mutable snapshot's changes are invisible elsewhere until apply(); if another snapshot changed the same state first, the conflict is detected (and merged if the policy allows) instead of silently losing an update.
Snapshot.withMutableSnapshot { } applies several writes at once, so no reader sees a half-updated state. Writes in the global snapshot are published to the Recomposer by Snapshot.sendApplyNotifications(), which Compose schedules for you.
What it guarantees
Snapshot state can be read and written from any thread without locks.
One frame never mixes old and new values of related state.
Consequences
Only snapshot state takes part: a MutableList inside mutableStateOf or a normal property is invisible.
snapshotFlow and derivedStateOf are built on the same read tracking.
Writing state during composition that was already read in the same composition forces another pass and can loop.