'java.lang.IllegalStateException when using State in Android Jetpack Compose

I have ViewModel with Kotlin sealed class to provide different states for UI. Also, I use androidx.compose.runtime.State object to notify UI about changes in state.

If error on MyApi request occurs, I put UIState.Failure to MutableState object and then I get IllegalStateException:

 java.lang.IllegalStateException: Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied
        at androidx.compose.runtime.snapshots.SnapshotKt.readError(Snapshot.kt:1524)
        at androidx.compose.runtime.snapshots.SnapshotKt.current(Snapshot.kt:1764)
        at androidx.compose.runtime.SnapshotMutableStateImpl.setValue(SnapshotState.kt:797)
        at com.vladuken.compose.ui.category.CategoryListViewModel$1.invokeSuspend(CategoryListViewModel.kt:39)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
        at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:738)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)

ViemModel code:

@HiltViewModel
class CategoryListViewModel @Inject constructor(
    private val api: MyApi
) : ViewModel() {

    sealed class UIState {
        object Loading : UIState()
        data class Success(val categoryList: List<Category>) : UIState()
        object Error : UIState()
    }

    val categoryListState: State<UIState>
        get() = _categoryListState
    private val _categoryListState =
        mutableStateOf<UIState>(UIState.Loading)

    init {
        viewModelScope.launch(Dispatchers.IO) {
            try {
                val categories = api
                    .getCategory().schemas
                    .map { it.toDomain() }
                _categoryListState.value = UIState.Success(categories)

            } catch (e: Exception) {
                //this does not work
                _categoryListState.value = UIState.Error
            }
        }
    }

}

I tried to delay setting UIState.Error - and it worked, but I don't think it is normal solution:

viewModelScope.launch(Dispatchers.IO) {
            try {
                val categories = api
                    .getCategory().schemas
                    .map { it.toDomain() }
                _categoryListState.value = UIState.Success(categories)

            } catch (e: Exception) {
                //This works 
                delay(10)
                _categoryListState.value = UIState.Error
            }
        }

I observe State object in Composable function as follows:

@Composable
fun CategoryScreen(
    viewModel: CategoryListViewModel,
    onCategoryClicked: (Category) -> Unit
) {
    when (val uiState = viewModel.categoryListState.value) {
        is CategoryListViewModel.UIState.Error -> CategoryError()
        is CategoryListViewModel.UIState.Loading -> CategoryLoading()
        is CategoryListViewModel.UIState.Success -> CategoryList(
            categories = uiState.categoryList,
            onCategoryClicked
        )
    }
}

Compose Version : 1.0.0-beta03

How to process sealed class UIState with Compose State so that it doesn't throw IllegalStateException?



Solution 1:[1]

So, after more attempts of fixing this issue I found a solution. With help of https://stackoverflow.com/a/66892156/13101450 answer I've get that snapshots are transactional and run on ui thread - changing dispatcher helped:

viewModelScope.launch(Dispatchers.IO) {
            try {
                val categories = api
                    .getCategory().schemas
                    .map { it.toDomain() }
                _categoryListState.value = UIState.Success(categories)

            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    _categoryListState.value = UIState.Error
                }
            }
        }

Solution 2:[2]

There's a discussion about what looks like somewhat similar issue in https://kotlinlang.slack.com/archives/CJLTWPH7S/p1613581738163700.

Some relevant parts of that discussion I think (from Adam Powell)

As for the thread-safety aspects of snapshot state, what you've encountered is the result of snapshots being transactional.

When a snapshot is taken (and composition does this for you under the hood) the currently active snapshot is thread-local. Everything that happens in composition is part of this transaction, and that transaction hasn't committed yet.

So when you create a new mutableStateOf in composition and then pass it to another thread, as the GlobalScope.launch in the problem snippet does, you've essentially let a reference to snapshot state that doesn't exist yet escape from the transaction.

The exact scenario is a little different here but I think same key issue. Probably wouldn't do it exactly this way but at least here it worked by moving contents of init in to new getCategories() method which is then called from LaunchedEffect block. FWIW what I've done elsewhere in case like this (while still invoking in init) is using StateFlow in view model and then call collectAsState() in Compose code.

@Composable
fun CategoryScreen(
    viewModel: CategoryListViewModel,
    onCategoryClicked: (Category) -> Unit
) {
    LaunchedEffect(true) {
        viewModel.getCategories()
    }

    when (val uiState = viewModel.categoryListState.value) {
        is CategoryListViewModel.UIState.Error -> CategoryError()
        is CategoryListViewModel.UIState.Loading -> CategoryLoading()
        is CategoryListViewModel.UIState.Success -> CategoryList(
            categories = uiState.categoryList,
            onCategoryClicked
        )
    }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Vladuken
Solution 2 theapache64