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 framesduring composition Reading a state that was created after the snapshot was takenerror- 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)
remembercreating new state on every recomposition- State change in
LaunchedEffectkey that changes on every recomposition derivedStateOfnot used for computed state causing unnecessary recompositions- Mutable state read and written in the same composition
Step-by-Step Fix
- 1.**Never modify state during composition":
- 2.```kotlin
- 3.// WRONG - state modified during composition causes infinite loop
- 4.@Composable
- 5.fun BuggyScreen(viewModel: MyViewModel) {
- 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.**Use derivedStateOf to prevent unnecessary recompositions":
- 2.```kotlin
- 3.@Composable
- 4.fun UserList(users: List<User>, searchQuery: String) {
- 5.// WRONG - recomposes every time users OR searchQuery changes
- 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.**Fix scroll state causing recomposition loop":
- 2.```kotlin
- 3.@Composable
- 4.fun ScrollableList(items: List<Item>) {
- 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.**Use snapshotFlow for observing Compose state in coroutines":
- 2.```kotlin
- 3.@Composable
- 4.fun SearchScreen(viewModel: SearchViewModel) {
- 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
MutableStatedirectly in composable body - Use
LaunchedEffect,DisposableEffect, orSideEffectfor side-effects - Use
derivedStateOffor computed values from state - Use
produceStateorsnapshotFlowto observe Compose state in coroutines - Enable
Composerdebug mode to track recomposition counts - Test composables with Compose testing library for recomposition issues