Search code examples
androidkotlinandroid-jetpack-composecompose-recomposition

How to fix "Unsupported concurrent change during composition"?


Error:

Recomposer.applyAndCheck
java.lang.IllegalStateException - Unsupported concurrent change during composition. A state object was modified by composition as well as being modified outside composition.

From time to time I have error with unsupported concurrent change. I try to understand the source of the problem. When I was learning composable I was using coroutineScope with Dispatchers.IO to update state and it cause the same problem. I have read https://developer.android.com/develop/ui/compose/side-effects and I do not see any issues in my code.

What kind of problem is this? Maybe during changes on state I should use viewModelScope?

is HomeEvent.OnFreeCouponDeclined -> {
    homeState.update {
        copy(isDeclined = false)
    }
}

should it be?

is HomeEvent.OnFreeCouponDeclined -> {
    viewModelScope.launch {
        homeState.update {
            copy(isDeclined = false)
        }
    }
}

Maybe here, could it be wrong?

private fun observeInventory() {
    viewModelScope.launch {
        (Dispatchers.Default) {
            getInventoryInteractor.invoke().collectLatest {
                (Dispatchers.Main) {
                    homeState.value = homeState.value.copy(categories = it.map {
                        it.copy(subCategories = emptyList())
                    })
                }
            }
        }
    }
}

It looks I sometimes use viewModelScope to change state and sometimes not. Please I kindly ask you for advice, what I could look for?

Maybe this code?

LaunchedEffect("${state.currentPage}_${sliderHoldTimestamp}_${sliderItems.size}") {
    delay(AUTO_SLIDER_DELAY)
    coroutineScope.launch {
        if (sliderItems.isNotEmpty() && state.pageCount != 0) {
            var newPosition = state.currentPage + 1
            if (newPosition > sliderItems.size - 1) newPosition = 0
            state.animateScrollToPage(newPosition.mod(state.pageCount))
        }
    }
}

This is auto slider, I had to workaround it like this because of the update from accompanist to newer approach.

import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState

const val foundation = "androidx.compose.foundation:foundation:1.6.1"

Solution

  • This is probably caused by updating a State object from different threads at the same time.

    The most effective way to solve this is to restrict updating State objects to the main thread, preferably exclusively to your composables. The general idea is that the view model retrieves data and exposes it as a StateFlow (which, despite its name, has nothing to do with Compose State). This way you won't update any State objects in your view model (because there are none) and only your composables will finally collect the flow. The latter will only ever happen on the main thread so there shouldn't be any concurrent updates anymore.

    You only provided a partial picture of how your view model looks like and what it does, but my best guess is that this would be an adequate replacement:

    private val isDeclined = MutableStateFlow(false)
    
    val homeState: StateFlow<HomeState?> = combine(
        getInventoryInteractor(),
        isDeclined,
    ) { inventory, isDeclined ->
        HomeState(
            categories = inventory.map {
                it.copy(subCategories = emptyList())
            },
            isDeclined = isDeclined,
        )
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = null,
    )
    
    fun handleEvent(event: HomeEvent) {
        when (event) {
            is HomeEvent.OnFreeCouponDeclined -> isDeclined.value = false // or should it be "true"?
        }
    }
    

    As you can see no flows are collected, no State objects updated, only the existing flows are transformed. I also introduced a new light-weight MutableStateFlow for isDeclined so it can be combined with the flow returned from getInventoryInteractor. You might want to change the initialValue of stateIn to whatever homeState should be until all flows provided their first value.

    Your actual code is probably more complex, but you should be able to refactor it to conform to the new structure.

    In you composables you then simply collect the homeState flow:

    val homeState by viewModel.homeState.collectAsStateWithLifecycle()
    

    You need the gradle dependency androidx.lifecycle:lifecycle-runtime-compose for this.