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
  • StackOverflowError during 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
  • LaunchedEffect with a key that changes every recomposition
  • Creating new objects in composition that trigger state comparison
  • Side effects without proper LaunchedEffect or DisposableEffect

Step-by-Step Fix

  1. 1.Move state changes to side effects:
  2. 2.```kotlin
  3. 3.// WRONG: state change during composition
  4. 4.@Composable
  5. 5.fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
  6. 6.val count by viewModel.count
  7. 7.if (count < 10) {
  8. 8.viewModel.increment() // Infinite loop!
  9. 9.}
  10. 10.Text("Count: $count")
  11. 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. 1.Use proper side effect APIs:
  2. 2.```kotlin
  3. 3.@Composable
  4. 4.fun UserProfile(userId: String, viewModel: ProfileViewModel = viewModel()) {
  5. 5.// LaunchedEffect: run suspend function when key changes
  6. 6.LaunchedEffect(userId) {
  7. 7.viewModel.loadProfile(userId)
  8. 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. 1.Avoid creating new objects in composition:
  2. 2.```kotlin
  3. 3.// WRONG: creates new object every recomposition
  4. 4.@Composable
  5. 5.fun UserCard(user: User) {
  6. 6.val colors = listOf(Color.Red, Color.Blue, Color.Green) // New list each time
  7. 7.Card(colors = colors) { ... }
  8. 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. 1.Debug recomposition with Layout Inspector:
  2. 2.```kotlin
  3. 3.// Add recomposition counting
  4. 4.@Composable
  5. 5.fun DebugRecompositions(content: @Composable () -> Unit) {
  6. 6.var recompositions by remember { mutableStateOf(0) }
  7. 7.recompositions++
  8. 8.Log.d("Compose", "Recompositions: $recompositions")
  9. 9.content()
  10. 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. 1.Use derivedStateOf for computed state:
  2. 2.```kotlin
  3. 3.@Composable
  4. 4.fun UserList(users: List<User>) {
  5. 5.// Without derivedStateOf: recomposes every time users list changes
  6. 6.// even if the filtered result is the same
  7. 7.val filteredUsers = derivedStateOf {
  8. 8.users.filter { it.isActive }
  9. 9.}

LazyColumn { items(filteredUsers.value) { user -> UserItem(user) } } } ```

Prevention

  • Never modify state inside the composable function body
  • Use LaunchedEffect, DisposableEffect, and SideEffect for side effects
  • Use remember and rememberUpdatedState to avoid recreating objects
  • Use derivedStateOf for computed state to minimize recompositions
  • Enable recomposition counting in Android Studio Layout Inspector
  • Follow the "composition is a function of state" principle