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 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.
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.
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")
}
}
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.
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
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.
| 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 |
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.
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)
}
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).
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))
}
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.
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 (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.
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>())
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)
}
}
}
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(...) }
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.
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(userId) {
// Runs when `userId` changes. Previous coroutine is cancelled first.
val profile = repository.fetchProfile(userId)
onProfileLoaded(profile)
}
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) onResume()
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
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") }
GlobalScope or manually creating coroutine scopes inside composables. They will outlive the composition and leak resources.
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
}
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. |
MutableList or MutableMap directly — use mutableStateListOf / mutableStateMapOf or snapshot-backed wrappers.remember inside if branches or forEach — violates the Rules of Hooks; composition order must be stable.MutableState between composables from different subtrees via a global object — hoist via ViewModel or CompositionLocal instead.LaunchedEffect(Unit) when the key should actually be a piece of state — the effect will not re-run on relevant changes.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
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.