Introduction

Jetpack Compose's state hoisting pattern moves state up to a common ancestor and passes it down as parameters. However, when state is modified during composition (not in a side-effect), it triggers an immediate recomposition, which modifies state again, creating an infinite loop. The app freezes or throws StateException: Reading a state that was created after the snapshot was taken after recomposing too many times.

Symptoms

  • App freezes with no error message (infinite recomposition)
  • Logcat shows Skipped 1000 frames during composition
  • Reading a state that was created after the snapshot was taken error
  • Compose preview hangs and must be restarted
  • Slider or TextField triggers continuous recomposition

Debug recomposition: ``kotlin @Composable fun DebugRecomposition() { var recompositions by remember { mutableStateOf(0) } recompositions++ Log.d("Recomposition", "Recomposed $recompositions times") // If this number keeps increasing without user interaction, there is a loop }

Common Causes

  • State modified directly in composable body (not in side-effect)
  • remember creating new state on every recomposition
  • State change in LaunchedEffect key that changes on every recomposition
  • derivedStateOf not used for computed state causing unnecessary recompositions
  • Mutable state read and written in the same composition

Step-by-Step Fix

  1. 1.**Never modify state during composition":
  2. 2.```kotlin
  3. 3.// WRONG - state modified during composition causes infinite loop
  4. 4.@Composable
  5. 5.fun BuggyScreen(viewModel: MyViewModel) {
  6. 6.val state by viewModel.state.collectAsState()

// This modifies state during composition - INFINITE LOOP! if (state.isLoading) { viewModel.loadData() // Triggers recomposition -> calls loadData again }

if (state.data != null) { Content(state.data) } }

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

// LaunchedEffect runs only when key changes LaunchedEffect(Unit) { if (state.isLoading) { viewModel.loadData() } }

if (state.data != null) { Content(state.data) } } ```

  1. 1.**Use derivedStateOf to prevent unnecessary recompositions":
  2. 2.```kotlin
  3. 3.@Composable
  4. 4.fun UserList(users: List<User>, searchQuery: String) {
  5. 5.// WRONG - recomposes every time users OR searchQuery changes
  6. 6.val filtered = users.filter { it.name.contains(searchQuery, ignoreCase = true) }

// CORRECT - only recomposes when the RESULT changes val filtered by remember(users, searchQuery) { derivedStateOf { users.filter { it.name.contains(searchQuery, ignoreCase = true) } } }

LazyColumn { items(filtered) { user -> UserRow(user) } } } ```

  1. 1.**Fix scroll state causing recomposition loop":
  2. 2.```kotlin
  3. 3.@Composable
  4. 4.fun ScrollableList(items: List<Item>) {
  5. 5.val listState = rememberLazyListState()

// WRONG - scrolling triggers recomposition which triggers scroll LaunchedEffect(items) { // This runs every time items changes, including during scroll listState.scrollToItem(0) // Causes scroll -> recomposition -> loop }

// CORRECT - only scroll when items significantly change var previousSize by remember { mutableStateOf(0) } LaunchedEffect(items.size) { if (previousSize == 0 && items.isNotEmpty()) { listState.scrollToItem(0) } previousSize = items.size }

LazyColumn(state = listState) { items(items) { item -> ItemRow(item) } } } ```

  1. 1.**Use snapshotFlow for observing Compose state in coroutines":
  2. 2.```kotlin
  3. 3.@Composable
  4. 4.fun SearchScreen(viewModel: SearchViewModel) {
  5. 5.var query by remember { mutableStateOf("") }

// Observe Compose state as a Flow val searchResults by produceState(initialValue = emptyList<Result>()) { snapshotFlow { query } .debounce(300) .filter { it.isNotBlank() } .distinctUntilChanged() .collect { q -> value = viewModel.search(q) } }

Column { OutlinedTextField( value = query, onValueChange = { query = it }, placeholder = { Text("Search...") } ) SearchResults(results = searchResults) } } ```

Prevention

  • Never modify MutableState directly in composable body
  • Use LaunchedEffect, DisposableEffect, or SideEffect for side-effects
  • Use derivedStateOf for computed values from state
  • Use produceState or snapshotFlow to observe Compose state in coroutines
  • Enable Composer debug mode to track recomposition counts
  • Test composables with Compose testing library for recomposition issues