Common Android & Compose Pitfalls
Compose's declarative model removes a lot of the boilerplate and manual bookkeeping of the View system, but it introduces its own set of subtle traps for developers coming from imperative UI toolkits, or even from earlier Compose code that predates best practices. Most of these pitfalls share a common root: misunderstanding when recomposition happens, what survives it, and what scope a piece of state or a coroutine actually lives in. This topic walks through the mistakes that show up most often in real codebases and code review, with the reasoning for why each one is wrong and what to do instead.
Cricket analogy: Just as a captain who doesn't understand DRS reviews keeps burning them at the wrong moments, a developer who doesn't understand recomposition scope keeps triggering rebuilds at the wrong moments in the UI tree.
Unstable Lambdas and Unnecessary Recomposition
A very common performance pitfall is creating a new lambda instance on every recomposition of a parent, then passing it to a child composable, which can defeat Compose's skipping optimization if the child's other parameters are otherwise stable — Compose sees a 'new' lambda reference each time and may treat the child as needing recomposition. The usual fix is to hoist the lambda reference (e.g. a ViewModel method reference like viewModel::onItemClick) so the same instance is reused, or to wrap ad-hoc lambdas in remember when they close over values that change infrequently. This matters more in hot paths like LazyColumn items than in one-off top-level screens.
Cricket analogy: It's like a bowler resetting their run-up mark before every single delivery instead of reusing the same fixed mark — the extra unnecessary setup work slows the over down, just as a fresh lambda each recomposition defeats skipping.
Reading State Too High in the Tree
Another frequent mistake is reading a frequently changing State value (like a scroll offset or an animated value) at a high level in the composable tree, near the root, instead of down inside the specific composable that actually needs it. Because recomposition scope is determined by where a State is read, reading it high up forces a large portion of the tree to recompose on every change, even though only one small leaf composable actually uses the value. Pushing the read down — sometimes via a lambda-based modifier like Modifier.graphicsLayer { ... } which reads state during the layout/draw phase rather than composition — confines the recomposition (or avoids it altogether) to the smallest possible scope.
Cricket analogy: Reading the pitch report at the team hotel instead of walking out to check it at the crease means the whole squad reacts to information nobody local actually needed, like reading fast-changing state high in the tree instead of down at the leaf that uses it.
// Pitfall: reading scroll state high up forces the whole screen to recompose on every scroll tick.
@Composable
fun ProfileScreen(listState: LazyListState) {
val offset = listState.firstVisibleItemScrollOffset // read here -> big recomposition scope
Column {
Header(offset)
ProfileList(listState)
}
}
// Fix: defer the read into the draw phase via graphicsLayer, avoiding composition-level recomposition.
@Composable
fun ProfileScreen(listState: LazyListState) {
Column {
Header(
modifier = Modifier.graphicsLayer {
translationY = -listState.firstVisibleItemScrollOffset.toFloat() * 0.5f
}
)
ProfileList(listState)
}
}The Layout Inspector's recomposition counts (or the 'Recomposition Highlighter' in newer Android Studio versions) are the single most effective diagnostic tool for these issues — rather than guessing, enable them and watch which composables light up on an interaction that shouldn't affect them, then trace the State read causing it.
Launching a coroutine directly with GlobalScope.launch inside a composable or ViewModel is a classic pitfall: it is not tied to any lifecycle, so it keeps running (and can leak) even after the screen or ViewModel is gone. Use viewModelScope in a ViewModel, or LaunchedEffect / rememberCoroutineScope inside a composable, so the coroutine is automatically cancelled when its owning scope ends.
A related, subtler pitfall: declaring var someValue = mutableStateOf(...) as a plain property inside a composable function body without wrapping it in remember { } is a subtle but serious bug: on every recomposition, the composable function re-executes from the top, creating a brand-new MutableState instance and silently discarding the previous one, so any user interaction that updated the value gets reset the moment something else triggers recomposition. The fix is always to wrap state creation in remember (or rememberSaveable when persistence across configuration change is needed) so the same instance is reused across recompositions of that call site.
Cricket analogy: It's like a scorer who re-opens a brand-new blank scorebook every over instead of continuing the same book — every wicket and run tally recorded earlier is silently wiped, just as an un-remembered mutableStateOf resets on every recomposition.
- Unstable lambdas passed to children can defeat Compose's skipping optimization; hoist or remember them in hot paths.
- Reading frequently changing state high in the tree widens the recomposition scope unnecessarily; push reads down, or defer to draw phase via graphicsLayer.
- Use Layout Inspector's recomposition counts to diagnose unexpected recomposition rather than guessing.
- Never use GlobalScope for coroutines tied to UI or ViewModel logic; use viewModelScope or LaunchedEffect/rememberCoroutineScope instead.
- Forgetting to wrap mutableStateOf(...) in remember silently resets state on every recomposition.
- Most Compose pitfalls trace back to misunderstanding recomposition scope or coroutine/state lifetime.
Practice what you learned
1. Why can passing a newly created lambda to a child composable on every recomposition hurt performance?
2. What is the effect of reading a frequently changing State value near the root of the composable tree?
3. What is wrong with using GlobalScope.launch inside a ViewModel for a network call?
4. What bug occurs if you write `val state = mutableStateOf(0)` directly in a composable body without remember?
5. What is an effective way to diagnose unexpected/excessive recomposition in a real app?
Was this page helpful?
You May Also Like
List Performance and Recomposition
Learn how Compose decides which list items to redraw, and how keys, stable types, and lambdas keep LazyColumn scrolling smoothly under real-world data loads.
mutableStateOf and Recomposition
Dive into how mutableStateOf creates observable state that triggers recomposition, and how Compose determines which composables need to redraw.
derivedStateOf and Side Effects
Learn how derivedStateOf avoids unnecessary recomposition for computed values, and how effect handlers like LaunchedEffect manage side effects safely.
Android & Jetpack Compose Quick Reference
A condensed reference of the core Jetpack Compose APIs, state tools, and architecture building blocks for fast lookup while coding.