Introduction

Jetpack Compose recomposes functions when their state inputs change. When a composable changes state during its own composition, it triggers a recomposition, which changes state again, creating an infinite loop. This typically happens when calling state-modifying functions directly in the composable body, using LaunchedEffect with a key that changes every recomposition, or mutating state in a SideEffect block without proper guards. The infinite loop causes the app to freeze, the UI thread to max out at 100% CPU, and eventually triggers an ANR (Application Not Responding) dialog.

Symptoms

  • App freezes shortly after navigating to a Compose screen
  • Logcat shows recomposition happening hundreds of times per second
  • UI thread CPU usage at 100%
  • ANR dialog appears after a few seconds
  • Screen flickers or updates rapidly
  • LaunchedEffect restarts on every recomposition

Error output: `` W/Compose: Recomposition count exceeded 100 in 1 second I/Choreographer: Skipped 120 frames! The application may be doing too much work. W/art: Long monitor contention with owner main (thread=1)

Common Causes

  • State modified directly in composable body (not in a side effect)
  • LaunchedEffect key changes every recomposition (e.g., using an object or list as key)
  • remember without proper key, creating new state on every recomposition
  • Mutable state read and written in the same composition pass
  • Callback triggered during composition that updates parent state

Step-by-Step Fix

  1. 1.Never modify state directly in composable body:
  2. 2.```kotlin
  3. 3.// WRONG - state change during composition triggers recomposition loop
  4. 4.@Composable
  5. 5.fun BadScreen(viewModel: MyViewModel) {
  6. 6.val state by viewModel.state.collectAsState()

// This runs during composition and changes state -> infinite loop if (state.isLoading) { viewModel.loadData() // This changes isLoading -> triggers recomposition }

if (state.data.isNotEmpty()) { DataList(state.data) } }

// CORRECT - use LaunchedEffect for side effects @Composable fun GoodScreen(viewModel: MyViewModel) { val state by viewModel.state.collectAsState()

// LaunchedEffect runs AFTER composition, not during LaunchedEffect(Unit) { // Unit = runs only once viewModel.loadData() }

if (state.isLoading) { CircularProgressIndicator() } else if (state.data.isNotEmpty()) { DataList(state.data) } else { Text("No data") } }

// Another common mistake - reading and writing state in the same composition @Composable fun BadCounter() { var count by remember { mutableStateOf(0) }

// This changes state during composition -> recomposition loop count++ // WRONG!

Text("Count: $count") }

// CORRECT - use button click or event to change state @Composable fun GoodCounter() { var count by remember { mutableStateOf(0) }

Button(onClick = { count++ }) { Text("Count: $count") } } ```

  1. 1.Fix LaunchedEffect key to prevent restart loop:
  2. 2.```kotlin
  3. 3.// WRONG - key changes every recomposition, restarting LaunchedEffect
  4. 4.@Composable
  5. 5.fun UserScreen(userId: String, viewModel: UserViewModel) {
  6. 6.// viewModel instance or a List as key changes on every recomposition
  7. 7.LaunchedEffect(viewModel) { // viewModel is NOT stable across recompositions
  8. 8.viewModel.loadUser(userId)
  9. 9.}
  10. 10.}

// CORRECT - use stable keys (primitives, strings, data class instances) @Composable fun UserScreen(userId: String, viewModel: UserViewModel) { LaunchedEffect(userId) { // String is stable - only changes when userId changes viewModel.loadUser(userId) } }

// When you need to react to multiple values, use a data class key @Composable fun SearchScreen(query: String, filters: List<String>, viewModel: SearchViewModel) { // WRONG - List creates a new instance on every recomposition LaunchedEffect(filters) { viewModel.search(query, filters) }

// CORRECT - derive a stable key val searchKey = remember(query, filters) { query to filters.size } LaunchedEffect(searchKey) { viewModel.search(query, filters) } } ```

  1. 1.Use derivedStateOf to prevent unnecessary recompositions:
  2. 2.```kotlin
  3. 3.// WRONG - every scroll position change triggers recomposition
  4. 4.@Composable
  5. 5.fun ScrollScreen(list: List<Item>) {
  6. 6.val scrollState = rememberScrollState()

// scrollState.value changes on every pixel of scroll val showButton = scrollState.value > 100 // Traces recomposition on every scroll

LazyColumn(state = scrollState) { items(list) { item -> ItemRow(item) } }

if (showButton) { FloatingActionButton(onClick = { /* ... */ }) { Icon(Icons.Default.Add, "Add") } } }

// CORRECT - use derivedStateOf to only recompose when showButton changes @Composable fun ScrollScreen(list: List<Item>) { val scrollState = rememberScrollState()

// Only triggers recomposition when the BOOLEAN result changes (at value 100) val showButton by remember { derivedStateOf { scrollState.value > 100 } }

LazyColumn(state = scrollState) { items(list) { item -> ItemRow(item) } }

if (showButton) { FloatingActionButton(onClick = { /* ... */ }) { Icon(Icons.Default.Add, "Add") } } }

// For Flow, use snapshotFlow @Composable fun ObserveScrollPosition(scrollState: ScrollState) { LaunchedEffect(scrollState) { snapshotFlow { scrollState.value } .distinctUntilChanged() .debounce(300) .collect { position -> Log.d("Scroll", "Position: $position") } } } ```

  1. 1.Debug recomposition with Layout Inspector:
  2. 2.```kotlin
  3. 3.// Enable recomposition counting in debug builds
  4. 4.@Composable
  5. 5.fun DebugRecompositions(content: @Composable () -> Unit) {
  6. 6.var recompositions by remember { mutableIntStateOf(0) }
  7. 7.var firstApplied by remember { mutableIntStateOf(0) }

CompositionLocalProvider( LocalRecompositionCount provides RecompositionCounter( onRecompose = { recompositions++ }, onFirstApply = { firstApplied++ } ) ) { content() }

if (BuildConfig.DEBUG) { Box(Modifier.align(Alignment.BottomEnd)) { Text( "Recompositions: $recompositions", style = MaterialTheme.typography.labelSmall, color = if (recompositions > 10) Color.Red else Color.Gray ) } } }

// Android Studio Layout Inspector shows recomposition counts // 1. Run app with debug build // 2. Tools > Layout Inspector // 3. Check "Show recomposition counts" in settings // 4. Green = 1-2 recompositions, Yellow = 3-15, Red = 15+ ```

Prevention

  • Never modify state in the composable body — always use side effects (LaunchedEffect, DisposableEffect)
  • Use stable, primitive keys for LaunchedEffect and remember
  • Wrap derived computations in derivedStateOf to prevent over-recomposition
  • Use @Stable or @Immutable annotations on data classes used as state
  • Enable recomposition counting in Android Studio Layout Inspector during development
  • Test composables with Compose testing library to detect infinite loops
  • Use snapshotFlow for observing state changes from outside composition