Build modern Android UI without fighting recompositions. The goal isn’t “never recompose” — it’s recompose cheaply, keep expensive work out of composition, and run side effects in the right place so your UI stays smooth (no flicker, no “why did this re-run?”, no random scroll jumps). This post gives you the mental model and a practical pattern you can reuse in real screens.
Quickstart
If your Compose UI feels jittery (flickering state, repeated network calls, list jumping, animations restarting), these are the fastest wins. Apply them in order.
1) Move state up (hoist) and model UI as one state
Keep ephemeral UI state in the UI, but keep screen state in a ViewModel and expose it as a single stream. This prevents “dueling sources of truth”.
- Prefer a single UiState data class over many independent booleans
- Expose state as StateFlow (or similar)
- Use collectAsStateWithLifecycle in UI
2) Stop doing work during composition
Composition should be “describe UI”, not “compute UI”. Expensive work belongs in effects or the ViewModel.
- Don’t allocate large objects in composables (formatters, regex, adapters)
- Use remember for stable objects and derivedStateOf for derived values
- Move I/O, database, and network calls out of composables
3) Key your effects correctly
Most “Compose is calling this twice” bugs are actually “my effect key is wrong”.
- LaunchedEffect(key) for coroutine work tied to a key
- DisposableEffect(key) for add/remove listeners
- rememberUpdatedState to avoid capturing stale lambdas
4) Make lists stable
Jittery lists come from unstable item identity or recreating lists every frame.
- Use key = { it.id } in LazyColumn
- Avoid rebuilding large lists in composition; derive/filter with derivedStateOf
- Keep item models immutable (and consistent equals/hashCode)
Compose recomposes often by design. Smooth UI comes from making recomposition cheap and preventing recomposition from triggering heavy work or unintended side effects.
Overview
Jetpack Compose is a declarative UI toolkit: your composables describe what the UI should look like for a given state. When state changes, Compose recomposes the parts of the tree that read that state.
This is powerful — but it also exposes a few common traps:
The 4 traps behind “UI that jitters”
| Trap | What you see | Why it happens |
|---|---|---|
| Work in composition | Scroll stutters, UI hitches | Heavy computation runs on every recomposition |
| Side effects in the wrong place | Repeated calls, duplicated toasts/snackbars | Effects restart due to wrong keys or recomposition |
| Two sources of truth | Flicker, state “snaps back” | UI and ViewModel both own the same state |
| Unstable identity | List items jump, animations reset | Items don’t have stable keys / models recreated |
In this post, you’ll learn a reusable pattern for Compose screens: a single immutable UiState from the ViewModel, event callbacks back to the ViewModel, and effects that are keyed to explicit inputs. Along the way we’ll cover when to use remember, rememberSaveable, derivedStateOf, and the common effect APIs (LaunchedEffect, DisposableEffect, SideEffect).
You don’t need to “eliminate recomposition”. You need to ensure a recompose is mostly cheap allocations + drawing, not I/O, not parsing, not building huge lists, and not re-running effects.
Core concepts
Compose feels simple on the surface — until you hit recomposition, state ownership, and side effects. These concepts are the “why” behind almost every fix.
State vs events vs effects
State (what the UI shows)
State is a snapshot of everything the UI needs to render: loading flags, content, errors, selection, etc. State should be read by composables.
- Prefer immutable data: data classes, Lists that aren’t mutated in place
- Model “loading + content + error” explicitly
- Keep state updates atomic (one update per user/action)
Events (what the user did)
Events are one-way signals back to the ViewModel: clicks, text changes, refresh, retry. Events should not directly mutate UI state in multiple places.
- UI emits events through callbacks
- ViewModel decides how to update state
- Prefer explicit functions: onQueryChanged, onRetry
Effects (things that happen because of state)
Effects are side work that should not run as part of rendering: starting a coroutine, registering a listener, scrolling a list, showing a snackbar, logging analytics. Effects must be explicitly scoped and keyed.
Recomposition: what triggers it (and why it’s not the enemy)
Recomposition happens when a composable reads state and that state changes. Compose then re-invokes the relevant composable functions to produce updated UI. The key idea: Compose may call your composable many times. That’s normal.
Treat composables like pure-ish functions: input = state, output = UI description. If your composable has hidden work (timers, network calls, heavy computation), recomposition will expose it.
Remember, rememberSaveable, and derivedStateOf
| Tool | Use it for | Common mistake |
|---|---|---|
| remember | Caching objects across recompositions (formatters, lambdas, controllers) | Forgetting keys and keeping stale data when inputs change |
| rememberSaveable | UI state that should survive configuration changes (e.g., text field value) | Saving large objects or non-saveable types without a Saver |
| derivedStateOf | Derived values that are expensive or should update only when dependencies change | Recomputing filters/sorts on every recomposition |
Stability and identity: why “same data” can still cause churn
Compose can skip work when inputs are stable and haven’t meaningfully changed. You get the best results when:
- Your UI models are immutable (or at least not mutated in place)
- List items have stable identity (id) and Lazy lists use keys
- You avoid creating “new but equal-looking” objects every recomposition
Building a new list on every recomposition (e.g., items.filter { ... } inline) can cause list state resets, dropped frames, and animation restarts—especially when combined with missing keys.
Step-by-step
Let’s build a practical pattern you can copy into real screens: a search + list UI that stays smooth while typing, doesn’t re-run network calls accidentally, and keeps list identity stable.
Step 1 — Define a single UiState (and keep it immutable)
A good UiState makes recomposition boring: Compose reads one object, and the screen renders from it. Put transient “input state” in the UI only when it truly belongs there (e.g., in-progress text editing that you don’t want to commit yet).
import androidx.compose.runtime.Immutable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@Immutable
data class SearchUiState(
val query: String = "",
val isLoading: Boolean = false,
val items: List<ResultItem> = emptyList(),
val errorMessage: String? = null
)
@Immutable
data class ResultItem(
val id: String,
val title: String,
val subtitle: String
)
class SearchViewModel {
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
fun onQueryChanged(newQuery: String) {
_uiState.value = _uiState.value.copy(query = newQuery, errorMessage = null)
}
fun onSearchStarted() {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
}
fun onSearchResult(items: List<ResultItem>) {
_uiState.value = _uiState.value.copy(isLoading = false, items = items)
}
fun onSearchError(message: String) {
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = message)
}
}
One state object means fewer “partial updates” that race each other. Immutability means changes are explicit, so Compose can reason about what changed and your own debugging becomes much simpler.
Step 2 — Collect state with lifecycle, derive expensive values once, and key list items
The UI’s job: render state and send events. Keep derived values out of the hot path. If you filter/sort lists based on query, derive it in a way that only recomputes when needed.
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun SearchScreen(
vm: SearchViewModel,
onItemClick: (String) -> Unit
) {
val state by vm.uiState.collectAsStateWithLifecycle()
// Derived value: only recalculates when state.query or state.items changes.
val visibleItems by remember(state.query, state.items) {
derivedStateOf {
val q = state.query.trim()
if (q.isEmpty()) state.items
else state.items.filter {
it.title.contains(q, ignoreCase = true) ||
it.subtitle.contains(q, ignoreCase = true)
}
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
OutlinedTextField(
value = state.query,
onValueChange = vm::onQueryChanged,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Search") }
)
Spacer(Modifier.height(12.dp))
if (state.isLoading) {
LinearProgressIndicator(Modifier.fillMaxWidth())
Spacer(Modifier.height(12.dp))
}
state.errorMessage?.let { msg ->
Text(
text = msg,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(12.dp))
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(
items = visibleItems,
key = { it.id } // Critical: stable identity prevents jumps and resets.
) { item ->
ListItem(
headlineContent = { Text(item.title) },
supportingContent = { Text(item.subtitle) },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
)
Divider()
}
}
}
}
Mini-checklist: smooth lists
- Use key in lazy lists (stable id)
- Avoid inline filter/sort in list builders
- Don’t mutate list items in place
- Keep item UI lightweight and stable
Mini-checklist: smooth typing
- Don’t run network calls on every keystroke in composition
- Debounce in an effect or ViewModel
- Update UI quickly; do work off the main thread
- Show loading state without rebuilding the world
Step 3 — Use effects for “do work”, with explicit keys and no loops
Effects are where most Compose “mystery behavior” lives. The fix is usually simple: pick the right effect API and choose the correct key.
Here’s a practical pattern: debounce the query and trigger a search. We’ll use snapshotFlow to observe Compose state changes inside a coroutine, then distinctUntilChanged and debounce to prevent work storms while typing.
import androidx.compose.runtime.*
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@OptIn(FlowPreview::class)
@Composable
fun SearchEffects(
query: String,
onDebouncedQuery: (String) -> Unit
) {
// Avoid capturing a stale lambda if onDebouncedQuery changes.
val latestOnDebouncedQuery by rememberUpdatedState(onDebouncedQuery)
LaunchedEffect(Unit) {
snapshotFlow { query }
.map { it.trim() }
.distinctUntilChanged()
.debounce(300)
.filter { it.length == 0 || it.length >= 2 }
.collect { latestOnDebouncedQuery(it) }
}
}
// Example usage inside a screen:
// SearchEffects(query = state.query) { q ->
// vm.onSearchStarted()
// vm.search(q) // your suspend/work function in the ViewModel
// }
If an effect updates state that retriggers the same effect, you can create a loop (or repeated work). A good rule: effects should react to inputs (keys), and state updates should be the result of a single path (usually the ViewModel).
Step 4 — Decide what belongs in UI state vs saved state vs ViewModel
Not all state is equal. Put state where it has the cleanest owner:
| State type | Owner | Examples |
|---|---|---|
| Screen data | ViewModel | Loaded content, loading flags, errors, server-driven UI |
| Transient UI | Composable | Expanded dropdown, local toggle before commit, animation flags |
| Needs to survive rotation | rememberSaveable | Text field value, selected tab, scroll position (when appropriate) |
If two places can modify the same “meaningful” state (e.g., selected item, auth status, filters), you’re one bug away from flicker. Pick one owner and send events to it.
Common mistakes
These are the patterns behind “Compose is weird” reports. The fixes are straightforward once you know where to look.
Mistake 1 — Calling suspend work from a composable body
If you start network/database work during composition, it can run repeatedly and block frames.
- Fix: trigger work from the ViewModel or from LaunchedEffect keyed to an input.
- Fix: keep composition “render-only”.
Mistake 2 — Using LaunchedEffect(Unit) when you actually need a key
Effects should re-run when their inputs change. If you use Unit everywhere, you’ll either miss updates or re-run for the wrong reasons.
- Fix: choose keys intentionally (e.g., selectedId, userId, query).
- Fix: for “run once per composition”, keep Unit — but only when that’s truly what you want.
Mistake 3 — Rebuilding lists and models on every recomposition
Inline filter/sort logic and fresh model allocations can cause jitter, especially in LazyColumn.
- Fix: use derivedStateOf for filtered/sorted lists.
- Fix: keep item models immutable and stable.
Mistake 4 — Missing keys in LazyColumn (identity breaks)
Without stable keys, Compose can’t reliably track which item is which across updates.
- Fix: provide key = { it.id }.
- Fix: avoid using list index as identity if the list can reorder.
Mistake 5 — Two sources of truth (UI + ViewModel both “own” the same field)
You’ll see flicker: UI updates, then snaps back when ViewModel state arrives.
- Fix: make the ViewModel the owner; UI sends events.
- Fix: if you need local “draft” state, keep it local and commit explicitly.
Mistake 6 — Capturing stale values inside effects
A coroutine in LaunchedEffect can keep an old lambda or old state reference.
- Fix: use rememberUpdatedState for callbacks and changing references.
- Fix: make keys explicit so effects restart when they should.
When something jitters, don’t guess. Narrow it down: which state changes? which composable reads it? Then move heavy work out of that composable and stabilize identity (keys + immutability).
FAQ
Do recompositions mean Compose is “rendering twice”?
No. Recomposition is Compose re-running composable functions to compute an updated UI tree. It’s normal and expected. Problems happen when recomposition triggers heavy work or side effects.
When should I use remember vs rememberSaveable?
Use remember to cache objects across recompositions inside the same composition. Use rememberSaveable for small UI state that should survive configuration changes (like rotation), such as a text field value or selected tab.
Why does my LaunchedEffect run again?
LaunchedEffect re-runs when its key(s) change (or when the composable leaves and re-enters composition). If you see repeated runs, your key is changing more often than you think (or you used an unstable key like a new object each time).
What’s the best way to collect Flow/StateFlow in Compose?
Prefer lifecycle-aware collection: collectAsStateWithLifecycle in UI. It avoids collecting when the UI isn’t started and reduces wasted work (and surprises during background/foreground transitions).
Should I store Compose mutableStateOf in the ViewModel?
You can, but many teams prefer StateFlow in the ViewModel and Compose State in the UI. The important part is consistency: one source of truth, and predictable updates.
How do I show one-off UI events like snackbars without repeating them?
Model one-off events separately from persistent UI state (e.g., an event stream), or include an event “token” in state and consume it once. Then trigger the snackbar from an effect keyed to that token.
My list scroll jumps when items update. What should I check first?
First, ensure LazyColumn has stable item keys. Second, avoid rebuilding the list in a way that changes identity. Third, verify you aren’t swapping entire list instances each frame due to constant recomputation.
Cheatsheet
Keep this as a quick reference when a screen feels “janky” or unpredictable.
State, events, effects: the rule-of-three
- State: immutable snapshot of what the UI should show
- Events: user/system actions sent upward (callbacks)
- Effects: side work (coroutines, listeners, scroll, snackbars) scoped + keyed
Effect API map (what to use when)
| API | Use case | Keying advice |
|---|---|---|
| LaunchedEffect | Run a coroutine tied to composable lifecycle | Key to the input that should restart the work |
| DisposableEffect | Add/remove listeners, observers, callbacks | Key to the thing you register against |
| SideEffect | Sync non-Compose state after successful recomposition | No keys; runs after every successful recomposition |
| rememberUpdatedState | Avoid stale captures in long-lived effects | Wrap changing lambdas/values used inside effects |
Jitter quick diagnosis
- Is heavy work happening during composition?
- Is an effect re-running due to a changing key?
- Is there more than one owner of a state value?
- Are list items missing stable keys?
Low-drama fixes that usually help
- Hoist state and expose one UiState
- Use derivedStateOf for filtered/sorted lists
- Move expensive work to ViewModel / effect
- Stabilize list identity (keys + immutable models)
Wrap-up
“UI that doesn’t jitter” in Jetpack Compose is mostly about discipline: keep composition cheap, keep state ownership clear, and run side effects with the right API and keys. Once you adopt a consistent pattern (UiState + events + keyed effects), Compose becomes predictable and fast to iterate on.
Next time something feels off, don’t fight recomposition — trace the state read, stabilize identity, and move work out of composition. Then use the cheatsheet as your debugging checklist.
If you want to build momentum, pair this post with Kotlin cleanup habits and performance profiling. The related posts below are curated to stack well with Compose state and effects.
Quiz
Quick self-check (demo). This quiz is auto-generated for mobile / development / android.