if you modify a mutablestate var in a coroutine wi...
# compose
p
if you modify a mutablestate var in a coroutine with Dispatchers.IO, can that be an issue if you need a composable to be recomposed when that mutablestate var has been changed? must it be modified in Dispatchers.Main to be able to triger a recomposition in any composable that observes it? I'm sorry if it is a noob question, but I'm having issues with this, and I'm searching on official docs and can't fint this point.
z
must it be modified in Dispatchers.Main to be able to triger a recomposition
Nope, you can write state objects from any thread. However, you may want to consider wrapping the write in a
Snapshot.withMutableSnapshot
especially if you're potentially updating multiple states, to ensure observers see all the changes together.
p
then why I have this problem? if I update the mutablestate var on a coroutine with Dispatchers.IO my composable doesn't recompose... but if I change the Dispatcher to Main, then it recomposes. Why then if not related to using Main?
z
can you share your code?
1
p
the state holder that has the variable:
Copy code
@Stable
class RateRequestDialogState(
    private val dataStoreRepository: DefaultDataStoreRepository,
    private val coroutineScope: CoroutineScope
) {
    var shouldDisplayDialog by mutableStateOf(false)
        private set
        
    private var dontShowAgain by mutableStateOf(false)
    private var launchCount by mutableIntStateOf(0)
    private var firstLaunchDate by mutableLongStateOf(0L)
    init {
        coroutineScope.launch(Dispatchers.IO) {
            dontShowAgain = dataStoreRepository.readBoolean(DataStoreHelper.Key.RATE_DIALOG_DONT_SHOW_AGAIN.value).first()

            if (dontShowAgain)
                return@launch

            // Increment launch counter
            launchCount = dataStoreRepository.readInt(DataStoreHelper.Key.RATE_DIALOG_LAUNCH_COUNT.value).first()
            launchCount++
            dataStoreRepository.saveInt(DataStoreHelper.Key.RATE_DIALOG_LAUNCH_COUNT.value, launchCount)

            // Get date of first launch
            firstLaunchDate = dataStoreRepository.readLong(DataStoreHelper.Key.RATE_DIALOG_FIRST_LAUNCH_DATE.value).first()
            if (firstLaunchDate == 0L) {
                firstLaunchDate = System.currentTimeMillis()
                dataStoreRepository.saveLong(
                    DataStoreHelper.Key.RATE_DIALOG_FIRST_LAUNCH_DATE.value,
                    firstLaunchDate
                )
            }

            // Wait at least n days before opening
            if (launchCount >= LAUNCHES_UNTIL_PROMPT) {
                if (System.currentTimeMillis() >= firstLaunchDate + (DAYS_UNTIL_PROMPT * 24 * 60 * 60 * 1000)) {
                    shouldDisplayDialog = true
                } 
            }
        }
    }
}
the remember function to generate the state holder and remember it:
Copy code
@Composable
fun rememberRateRequestDialogState(
    dataStoreRepository: DefaultDataStoreRepository,
    coroutineScope: CoroutineScope = rememberCoroutineScope()
): RateRequestDialogState {
    val rateRequestDialogState = remember(
        dataStoreRepository,
        coroutineScope
    ) {
        RateRequestDialogState(
            dataStoreRepository = dataStoreRepository,
            coroutineScope = coroutineScope
        )
    }
    DisposableEffect(Unit) {
        onDispose {
            rateRequestDialogState.dispose()
        }
    }
    return rateRequestDialogState
}
how I generate the state holder and how I use the variable in a composable:
Copy code
val stateHolder = rememberRateRequestDialogState(
    dataStoreRepository = koinInject()
)

if (stateHolder.shouldDisplayDialog)
    RateRequestDialog({ stateHolder.hideDialog() }, R.mipmap.ic_launcher_foreground)
}
the
if (stateHolder.shouldDisplayDialog)
of the composable is not called again when the
shouldDisplayDialog
is set to true in the init method of the state holder, because is not being recomposed. But it works if I change Dispatchers.IO to Dispatchers.Main in the init method of the state holder
I checked that the variable is being set to true, because I filled the code with a lot of logs everywhere
so for sure is not being recomposed when is set to true
if you wonder why I need to update it in a corutine, it's because in the if I need to check some values obtained from datastore
there is something very strange here, if I wrap the shouldDisplayDialog = true inside a withContext(Dispatchers.Main) to keep the other things in Dispatchers.IO.. the problem persistes. There is a ver strange behaviour that I don't understand. If I don't wrap everything under Dispatchers.Main, sometimes the value of launchCount is printed as 0 (default value) and the recomposition is not done. Can someone explain me what is happeing here?
z
I think you might be racing. If you try to set the value of that state object on a background thread before the snapshot that created it is applied, it used to crash, but I think now it’s a noop maybe?
It’s generally a bad practice to watch coroutines in a constructor anyway
If you do it in a launch effect, then you’re guaranteed to only run after the snapshots that the composition was performed in that initialized the object is applied
p
check this
I added these logs:
Copy code
// Increment launch counter
Log.d("XXXX", "DialogState before readint launchCount: ${launchCount}")
launchCount = dataStoreRepository.readInt(DataStoreHelper.Key.RATE_DIALOG_LAUNCH_COUNT.value).first()
Log.d("XXXX", "DialogState after readint launchCount: ${launchCount}")
launchCount++
Log.d("XXXX", "DialogState after adding 1 launchCount: ${launchCount}")
dataStoreRepository.saveInt(DataStoreHelper.Key.RATE_DIALOG_LAUNCH_COUNT.value, launchCount)
Log.d("XXXX", "DialogState after saveint launchCount: ${launchCount}")
check this rare behaviour:
Copy code
2025-03-05 23:10:31.284 10749-10769 XXXX <http://com.app|com.app> D DialogState before readint launchCount: 0 
2025-03-05 23:10:31.285 10749-10769 XXXX <http://com.app|com.app> D readInt returning flow 
2025-03-05 23:10:31.286 10749-10769 XXXX <http://com.app|com.app> D DialogState after readint launchCount: 0 
2025-03-05 23:10:31.291 10749-10769 XXXX <http://com.app|com.app> D DialogState after sumar 1 launchCount: 1 
2025-03-05 23:10:31.318 10749-10782 XXXX <http://com.app|com.app> D saveInt saving flow 
2025-03-05 23:10:31.566 10749-10782 XXXX <http://com.app|com.app> D DialogState after saveint launchCount: 0
"readInt returning flow" and "saveInt saving flow" are two logs inside the datastore functions
I'm very confussed
it's being executed secuentially inside the corutine, but after the save int, the value of launchCount is 0, that has no sense
and this happens only sometimes, some other times it is value 1
there is something I'm not understanding in how flow/datastore is working here
I'm trying to understand what you tell me before, maybe it's related with this, but it's hard to understand what you are trying to explain with that
I'm not sure if I understood the snapshot concept completly and don't know what you mean with a noop
but well, you mean doing this? first, migrating all the "init" contento to a fun initialize() function, and then do it this way on my composable:
Copy code
val stateHolder = rememberRateRequestDialogState(
    dataStoreRepository = koinInject()
)

LaunchedEffect(true) {
    stateHolder.initialize()
}

if (stateHolder.shouldDisplayDialog)
    RateRequestDialog({ stateHolder.hideDialog() }, R.mipmap.ic_launcher_foreground)
You mean that this is the correct way?
it seems to work, but I really don't understand why, and also, seems weird to initialize the state holder values with a launch effect and a initialize function and not in the init block. This seems to be very hard to understand for me in a year if I revisit the code for something
z
That definitely looks like a race
This is part of the reason why it's bad to kick off side effects (like making database calls) directly from composition, i.e. from inside a
remember
lambda, and also why doing side effects from a constructor is generally bad. Side effects should be a separate explicit thing from object construction (for this reason, and also for making testing easier)
👌 1
p
I moved them outside, to a viewmodel init block and now everything works fine