user21300258
user21300258

Reputation:

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"

Upvotes: 2

Views: 572

Answers (2)

chuckj
chuckj

Reputation: 29615

This exception is caused when you modify a snapshot state object inside the composition (e.g. directly in an @Composable function) and outside of composition (anywhere else). There is no safe way to do this. They only way to avoid this exception is to not write to a state object in both places.

The state object referred to by the exception is a snapshot state object, one created by one of the mutableStateOf() functions or other snapshot aware object such as a SnapshotStateList. It does not include any Flow<T> instances such as MutableStateFlow<T>. Any modification or transformation of a Flow<T> will not trigger this exception. collectAsState() bridges a Flow<T> to composition by creating a state object that records the current state of the flow. It does not write to this state object during composition (it is in a collect coroutine) so cannot cause this error. As all the examples above are about how the Flow<T> is updated, nothing you do to change them will help.

You need to look for writes to MutableState<T> objects directly in a @Composable function. These are almost always the wrong thing to do. A MutableState<T> should be modified either only in composition or only outside of composition, never both. Violating this rule will cause the concurrent change exception above.

Upvotes: 1

tyg
tyg

Reputation: 15803

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.

Upvotes: 0

Related Questions