Jetpack Compose is elegant. It's also a landmine.
The same reactive model that makes Compose declarative can silently swallow your coroutines, fire effects multiple times, and leave users staring at frozen spinners. I've seen these bugs ship to production—in my own apps and in code reviews across teams.
At Droidcon India 2025, I presented a talk on these patterns. This post goes deeper: the actual bugs, why Compose behaves this way internally, and how we fixed them in production at Equal AI.
The OTP That Never Verified: LaunchedEffect Self-Cancellation
This bug cost us a week of debugging. Users reported that SMS auto-fill "didn't work"—the OTP would populate, but verification never happened. The spinner just spun forever.
Here's the code that shipped:
var wasAutoFilled by remember { mutableStateOf(false) }
LaunchedEffect(wasAutoFilled) {
if (!wasAutoFilled) return@LaunchedEffect
wasAutoFilled = false // Reset for next time
delay(300) // Small delay to feel natural
onVerifyOTP(otpCode) // Verify the OTP
isVerifying = false // Hide spinner
}
What we expected:
What actually happened:
Why This Happens: Compose's Recomposition Timing
Here's the timeline:
The bug is subtle: changing the LaunchedEffect key schedules cancellation, but cancellation executes at the next suspension point. If you have no suspension, the code runs to completion before the frame boundary. Add a delay(), and you're dead.
The Fix: snapshotFlow Decouples Observation from Lifecycle
LaunchedEffect(Unit) { // Key is Unit — NEVER changes
snapshotFlow { wasAutoFilled }
.filter { it }
.collect {
wasAutoFilled = false // Safe! Just emits to the flow
delay(300) // Completes normally
onVerifyOTP(otpCode) // Actually executes
isVerifying = false
}
}
Why this works:
LaunchedEffect(Unit)starts once and never restartssnapshotFlowobserves state changes as Flow emissions- Changing
wasAutoFilledemits a new value—it doesn't cancel the collector
The Shopping Cart That Lost Items: Mutable Collection Mutation
A user reported: "I added 5 items to my cart, but only 2 showed up." We checked the database—all 5 were there. The bug was in the UI.
var cartItems by remember {
mutableStateOf(mutableListOf<CartItem>())
}
Button(onClick = {
cartItems.add(newItem) // Items added internally...
// ...but UI never updates!
})
Why This Happens: Reference Equality
Compose uses reference equality to detect state changes. When you call cartItems.add(), you're mutating the same list object:
The Fix: Immutable Updates or mutableStateListOf
// Option 1: Create new list
var cartItems by remember { mutableStateOf(listOf<CartItem>()) }
cartItems = cartItems + newItem // New reference
// Option 2: Use Compose's observable list
val cartItems = remember { mutableStateListOf<CartItem>() }
cartItems.add(newItem) // Automatically triggers recomposition
The Duplicate Snackbar: Events Are Not State
Error handling seemed simple:
// In ViewModel
var errorMessage by mutableStateOf<String?>(null)
// In Composable
LaunchedEffect(viewModel.errorMessage) {
viewModel.errorMessage?.let { error ->
snackbarHostState.showSnackbar(error)
}
}
Bug: Rotate the device while the snackbar is showing. It shows again. And again on every configuration change.
Why This Happens
errorMessage = "Save failed"The Fix: Use Channels for One-Time Events
// In ViewModel
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
// In Composable
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is UiEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
}
}
}
The Janky Scroll: State Read Too High
Performance profiling showed our list was recomposing on every frame during scroll. 60 recompositions per second.
@Composable
fun ProductListScreen() {
val scrollState = rememberLazyListState()
val showScrollToTop = scrollState.firstVisibleItemIndex > 5 // ← Read here!
Column {
TopBar(showScrollToTop) // Recomposes on scroll
ProductList(scrollState) // Recomposes on scroll
BottomNav() // Recomposes on scroll (!)
}
}
Why This Happens: Recomposition Scope
The Fix: Push State Reads Down
@Composable
fun ScrollAwareTopBar(scrollState: LazyListState) {
val showScrollToTop by remember {
derivedStateOf { scrollState.firstVisibleItemIndex > 5 }
}
// Only THIS composable recomposes on scroll
TopBar(showScrollToTop)
}
State Machines: Making Impossible States Impossible
All these bugs share a root cause: invalid state combinations that shouldn't exist.
// 4 booleans = 16 combinations. Valid states? Maybe 5.
data class CheckoutState(
val isLoading: Boolean,
val isError: Boolean,
val isSuccess: Boolean,
val isProcessingPayment: Boolean
)
The Fix: Sealed Interfaces
sealed interface CheckoutState {
data object Idle : CheckoutState
data object Loading : CheckoutState
data class ProcessingPayment(val order: Order) : CheckoutState
data class Success(val receipt: Receipt) : CheckoutState
data class Error(val message: String) : CheckoutState
}
Now only valid states can exist. The compiler enforces exhaustive handling.
Production Results
After implementing these patterns at Equal AI:
| Metric | Before | After |
|---|---|---|
| Crash rate | 0.4% | 0.1% |
| ANR rate | 0.2% | 0.05% |
| "UI stuck" reports | 23/week | 3/week |
| Test coverage (state) | 34% | 89% |
Resources
Slides: View on SpeakerDeck
Code: compose-patterns-playground
The playground includes interactive broken/fixed demos for all 12 anti-patterns.
Key Takeaways
- LaunchedEffect keys control lifecycle — Changing the key cancels the coroutine
- Compose uses reference equality — Mutating collections doesn't trigger recomposition
- Events are not state — Use Channel for one-time events
- State reads define recomposition scope — Read state as low as possible
- Sealed interfaces prevent impossible states — Boolean combinations explode
Compose isn't slow. Misusing Compose is slow. Learn the patterns, avoid the traps, ship fewer bugs.
Presented at Droidcon India 2025