One Off the Slack: Is Mutating State From a ViewModel Constructor OK?
TL;DR: don’t do that.
theapache64 asked:
Why changing snapshot state from background thread during composition causing a crash?
@Composable
fun MyComposable(
viewModel: MyViewModel = hiltViewModel()
) {
Text(text = viewModel.myState)
}
class MyViewModel : ViewModel() {
var myState by mutableStateOf("A")
private set
init {
viewModelScope.launch(Dispatchers.IO) {
myState = "B"
}
}
}
The above code (myState= “B”) will crash the app saying
Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied
PS: I read this thread but I don’t fully understand “why” the runtime had to throw that crash? Why it can’t handle the scenario gracefully ? Maybe update the UI with the new state (“B”)? And does it only happen during composition?
Then, theapache64 did something unfortunate: using @
notation to ping a bunch
of people directly, including Google developers. Almost every Google employee contributing
to Kotlinlang Slack is doing so voluntarily. As Google’s Adam Powell put it:
please don’t do this kind of mention storm. we’ll see threads here without people trying to light up our phones
Adam then went on to explain the problem with the code:
Since your ViewModel’s constructor has side effects you’ve leaked those side effects out of the composition snapshot and attempted to interact with that composition snapshot from outside before it’s been committed
ideally viewmodel construction should not have these kinds of side effects. This is why you’ll often see viewmodels with
suspend funs
invoked byLaunchedEffect
and similar, or use ofstateIn(..., SharingStarted.WhileSubscribed(), ...);
the viewmodel itself is generally inert unless acted on from outside
when a viewmodel has side effects of construction then it needs to concern itself with cases like the above, or rolling back those side effects if the composition it’s created in fails to apply, or ensuring those side effects don’t have lingering observable impact
Filip Wiesner then wanted confirmation that this statement (from earlier in the thread) made sense:
The problem is that you are changing a state that is not yet managed by any snapshot and the history of these changes cannot be “saved” anywhere.
Adam responded:
close enough to be a useful mental model. Think of it kind of like leaking
this
of a non-final class during construction; non-null things can be observed to be null, writes will be overwritten when the real constructor actually runs, lots of bad times
Filip was still confused:
I think that I still don’t understand the relation between State and the Snapshot 😞 Even after reading a lot about it. I thought of State being “just a dumb value holder” until some Snapshot takes control of it, observing and managing it’s changes.\
But from the way you are describing it, it feels more like they know already about each other but the parent is not yet ready to manage it until some preparations are completed (first composition?).
Does the question/comparison make sense?
Adam tried to clarify further:
Composition only matters insofar as composition is performed in a mutable snapshot that gets committed when recomposition is complete, it’s the snapshot transaction that matters
When you create a snapshot state holder, you’ve created it in the current snapshot. If that’s the global snapshot then it’s effectively created immediately, but if you’re in a snapshot that isn’t committed then the snapshot state holder doesn’t really exist until the snapshot is committed
So if you send (leak) a reference to it somewhere else and try to manipulate it from outside of that snapshot, it doesn’t exist until the original snapshot is committed so you can’t
That’s what’s happening in the OP, the ViewModel is launching a coroutine that runs on a different dispatcher on a different thread, and on that thread the composition snapshot isn’t the current snapshot
The composition snapshot isn’t committed yet, so when the background thread tries to access a state object that doesn’t exist in that timeline, boom.
There is more to the thread, including pointing out how the stack trace for this problem is not super-informative. 🙄
Read the original thread in the kotlinlang Slack workspace. Not a member? Join that Slack workspace here!