Answer
Use produceState (or a ViewModel‑exposed StateFlow) rather than LaunchedEffect when you need a retry loop that updates a MutableState exactly once per successful attempt and guarantees no access after disposal. produceState ties the coroutine lifecycle to the composition scope, automatically cancels the loop when the composable disposes, and prevents the state object from being disposed while the loop is still running.
Likely explanation
The IllegalStateException “State has been read after it was disposed” occurs when the composable that created the mutableStateOf is recomposed or disposed (e.g., because an exception triggered a restart) before the retry coroutine finishes. The State object’s dispose() is called, and any later get() throws.
Confirmed facts
- Jetpack Compose treats State objects as lifecycle‑bound; disposing the surrounding composition disposes the State.
produceState creates a coroutine scoped to the composition; when the composition leaves the tree, the coroutine is cancelled and the State is not accessed after disposal.
LaunchedEffect with a changing key can restart the effect while a previous retry loop is still running, leading to duplicate writes or accessing a disposed State if the key changes mid‑retry.
Steps to resolve
- Hoist the State out of the retry‑prone composable: either move it to a ViewModel (expose as
StateFlow or MutableState) or use rememberSaveable with a stable key that survives recomposition.
- Inside the ViewModel or a higher‑level composable, collect the flow with
collectAsState or expose the State directly.
- Implement the retry loop in a coroutine that is scoped to the ViewModel’s
viewModelScope (or to the produceState scope). Example:
// ViewModel
class MyViewModel : ViewModel() {
private val _result = mutableStateOf(null)
val result: State = _result
fun attemptRetry() {
viewModelScope.launch {
while (true) {
try {
val value = riskySuspendCall() // may throw
_result.value = Result.Success(value)
break
} catch (e: IOException) {
// transient failure – retry after delay
delay(1000)
}
}
}
}
}
// Composable
@Composable
fun MyScreen(vm: MyViewModel = viewModel()) {
val result by vm.result.collectAsState()
// UI based on result
}
Verification
- Check the logcat for the stack trace; the line invoking
get() on the State should point to the composable.
- Enable Compose debugging:
setComposeDebugTreeEnabled(true) and observe when the composition disposes relative to the retry loop.
- Create a minimal reproducer: move the State to a ViewModel as shown; if the error disappears, the original composition‑scoped State was the cause.
Missing diagnostic detail: Is the mutableStateOf created directly inside the composable that launches the retry, or is it obtained from a ViewModel? Knowing this determines whether hoisting to a ViewModel is sufficient or if a different scoping strategy is needed.