Introduction
Jetpack Compose recomposes the UI whenever observed state changes. If state is modified during the composition phase (inside a composable function body), it triggers another recomposition, which modifies state again, creating an infinite loop. This freezes the UI, drains the battery, and can crash the app with a stack overflow.
Symptoms
- UI freezes completely after navigating to a screen
- Logcat shows thousands of recomposition messages per second
StackOverflowErrorduring composition- App becomes unresponsive and ANRs
- Profiler shows 100% CPU usage on the main thread
Example problematic code: ```kotlin @Composable fun CounterScreen(viewModel: CounterViewModel = viewModel()) { val count by viewModel.count
// BUG: This triggers recomposition which changes state again if (count < 10) { viewModel.increment() // State change during composition! }
Text("Count: $count") } ```
Common Causes
- Modifying state inside composable function body
- Calling ViewModel methods that update state during composition
LaunchedEffectwith a key that changes every recomposition- Creating new objects in composition that trigger state comparison
- Side effects without proper
LaunchedEffectorDisposableEffect
Step-by-Step Fix
- 1.Move state changes to side effects:
- 2.```kotlin
- 3.// WRONG: state change during composition
- 4.@Composable
- 5.fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
- 6.val count by viewModel.count
- 7.if (count < 10) {
- 8.viewModel.increment() // Infinite loop!
- 9.}
- 10.Text("Count: $count")
- 11.}
// CORRECT: use LaunchedEffect for state-dependent side effects @Composable fun CounterScreen(viewModel: CounterViewModel = viewModel()) { val count by viewModel.count
LaunchedEffect(count) { if (count < 10) { viewModel.increment() } }
Text("Count: $count") } ```
- 1.Use proper side effect APIs:
- 2.```kotlin
- 3.@Composable
- 4.fun UserProfile(userId: String, viewModel: ProfileViewModel = viewModel()) {
- 5.// LaunchedEffect: run suspend function when key changes
- 6.LaunchedEffect(userId) {
- 7.viewModel.loadProfile(userId)
- 8.}
// DisposableEffect: cleanup when leaving DisposableEffect(userId) { viewModel.onEnter() onDispose { viewModel.onLeave() } }
// SideEffect: non-suspend side effects after composition SideEffect { analytics.trackScreenView("Profile") }
val profile by viewModel.profile ProfileContent(profile) } ```
- 1.Avoid creating new objects in composition:
- 2.```kotlin
- 3.// WRONG: creates new object every recomposition
- 4.@Composable
- 5.fun UserCard(user: User) {
- 6.val colors = listOf(Color.Red, Color.Blue, Color.Green) // New list each time
- 7.Card(colors = colors) { ... }
- 8.}
// CORRECT: remember the object @Composable fun UserCard(user: User) { val colors = remember { listOf(Color.Red, Color.Blue, Color.Green) } Card(colors = colors) { ... } }
// Or move constants outside private val DefaultColors = listOf(Color.Red, Color.Blue, Color.Green)
@Composable fun UserCard(user: User) { Card(colors = DefaultColors) { ... } } ```
- 1.Debug recomposition with Layout Inspector:
- 2.```kotlin
- 3.// Add recomposition counting
- 4.@Composable
- 5.fun DebugRecompositions(content: @Composable () -> Unit) {
- 6.var recompositions by remember { mutableStateOf(0) }
- 7.recompositions++
- 8.Log.d("Compose", "Recompositions: $recompositions")
- 9.content()
- 10.}
// Or use Android Studio's Layout Inspector: // 1. Run the app // 2. Tools > Layout Inspector // 3. Enable "Show Recomposition Counts" // 4. Watch for rapidly increasing counts ```
- 1.Use derivedStateOf for computed state:
- 2.```kotlin
- 3.@Composable
- 4.fun UserList(users: List<User>) {
- 5.// Without derivedStateOf: recomposes every time users list changes
- 6.// even if the filtered result is the same
- 7.val filteredUsers = derivedStateOf {
- 8.users.filter { it.isActive }
- 9.}
LazyColumn { items(filteredUsers.value) { user -> UserItem(user) } } } ```
Prevention
- Never modify state inside the composable function body
- Use
LaunchedEffect,DisposableEffect, andSideEffectfor side effects - Use
rememberandrememberUpdatedStateto avoid recreating objects - Use
derivedStateOffor computed state to minimize recompositions - Enable recomposition counting in Android Studio Layout Inspector
- Follow the "composition is a function of state" principle