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
LaunchedEffectrestarts 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)
LaunchedEffectkey changes every recomposition (e.g., using an object or list as key)rememberwithout 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.Never modify state directly in composable body:
- 2.```kotlin
- 3.// WRONG - state change during composition triggers recomposition loop
- 4.@Composable
- 5.fun BadScreen(viewModel: MyViewModel) {
- 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.Fix LaunchedEffect key to prevent restart loop:
- 2.```kotlin
- 3.// WRONG - key changes every recomposition, restarting LaunchedEffect
- 4.@Composable
- 5.fun UserScreen(userId: String, viewModel: UserViewModel) {
- 6.// viewModel instance or a List as key changes on every recomposition
- 7.LaunchedEffect(viewModel) { // viewModel is NOT stable across recompositions
- 8.viewModel.loadUser(userId)
- 9.}
- 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.Use derivedStateOf to prevent unnecessary recompositions:
- 2.```kotlin
- 3.// WRONG - every scroll position change triggers recomposition
- 4.@Composable
- 5.fun ScrollScreen(list: List<Item>) {
- 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.Debug recomposition with Layout Inspector:
- 2.```kotlin
- 3.// Enable recomposition counting in debug builds
- 4.@Composable
- 5.fun DebugRecompositions(content: @Composable () -> Unit) {
- 6.var recompositions by remember { mutableIntStateOf(0) }
- 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
derivedStateOfto prevent over-recomposition - Use
@Stableor@Immutableannotations 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
snapshotFlowfor observing state changes from outside composition