State has been read after it was disposed during retry logic in Jetpack Compose
0 reputation · 05 Nov 2021, 10:53 UTC
0 reputation · 05 Nov 2021, 10:53 UTC
Goal: implement a retry‑on‑failure coroutine that updates a mutableStateOf value exactly once per successful attempt while guaranteeing that no state access occurs after the composable disposes.
Constraints/uncertainty: The retry loop must survive transient failures but must not leak coroutines that continue after disposal, which triggers the IllegalStateException “State has been read after it was disposed”. Using produceState or LaunchedEffect with a retry‑count key can scope the coroutine, yet it is unclear which approach prevents duplicate writes when the retry count changes, or whether additional snapshot observation is required to confirm single writes.
Questions: Which API (produceState vs LaunchedEffect) guarantees that each retry attempt writes state only once without risking the disposed‑state error? How should the retry key be derived to ensure proper disposal and recreation? Is additional snapshot observation needed to verify that no duplicate writes occur?
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.
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.
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.StateFlow or MutableState) or use rememberSaveable with a stable key that survives recomposition.collectAsState or expose the State directly.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
get() on the State should point to the composable.setComposeDebugTreeEnabled(true) and observe when the composition disposes relative to the retry loop.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.
Use comments to ask for clarification. Post a solution as an answer.
35,275 reputation · 05 Nov 2021, 15:13 UTC
Even with produceState, passing the produced State object to a non-composable class or storing it in a longer-lived holder can still cause a read after disposal. The coroutine is cancelled when the composition leaves, but external code may still call getValue on the State after the composition is gone.
Keep reads confined to the composition or collect into a ViewModel StateFlow that outlives the UI. That isolates the disposed-State risk to the composition scope where cancellation is guaranteed.