54

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?

0

4 Answers 4

59

Three ways this can be resolved are

    1. To call the method in a launched effect block in your composable
    1. Or set the Context to Dispatchers.Main when setting value of the mutableState using withContext(Dispatchers.Main)
    1. Or change the mutable state in viewModel to mutableState flow and use collectAsState() in composable to collect it as a state.
Sign up to request clarification or add additional context in comments.

4 Comments

Very concise and accurate.
The third option is more suitable if you need to use Dispachers.IO like database operations. Accurate answer.
Great answer @Neo can you explain the reason ?
2 - so State is not thread safe when setting value from non-Main thread?
20

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
        )
    }
}

Comments

14

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
                }
            }
        }

3 Comments

dont use try catch use flow{ }.catch{}
This doesn't work for me. I get the following error: java.lang.IllegalStateException: Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied
This doesn't work for me.
2

Hate to say this but withContext(Dispatchers.Main) didn't worked for me. So I called the init method from LaunchedEffect from composable which works as expected.

class VideosViewModel : ViewModel() {
    fun init() {
        viewModelScope.launch {
            setState { copy(isLoading = true) }
            // work here
            setState { copy(isLoading = false) }
        }
    }
}

Composable

LaunchedEffect(key1 = Unit) {
    viewModel.init()
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.