I'm going through Advanced State and Side Effects in Jetpack Compose course and I have this code in the end of this chapter.
@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
...
val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
LaunchedEffect(editableUserInputState) {
snapshotFlow { editableUserInputState.text }
.filter { !editableUserInputState.isHint }
.collect {
currentOnDestinationChanged(editableUserInputState.text)
}
}
}
and editableUserInputState
looks like this:
class EditableUserInputState(private val hint: String, initialText: String) {
var text by mutableStateOf(initialText)
val isHint: Boolean
get() = text == hint
...
}
Is it true that after the first of two following lines some other thread can change editableUserInputState.text
and filter
part will check the updated value instead of the one we got in first line?
snapshotFlow { editableUserInputState.text }
.filter { !editableUserInputState.isHint }
Although Flows are thread-safe, this is not how flows should be used.
The flow operations are influenced by several side effects which may actually make this prone to race conditions, potentially leading to wrong results. The basic issue is that the chain of flow operations is not guaranteed to be executed en bloc (i.e. as one, atomic operation). The flow operation's lambdas are suspend functions and have a suspension point. That means when one operation finishes, the current coroutine running the flow operations can be suspended and another coroutine may be executed instead.
You don't have multiple threads involved (because the UI runs only on a single thread, the Main thread) so you don't have to worry about multiple coroutines running in parallel, but there is always the possibility the processing of the flow chain is paused after each operation because another coroutine now occipies the (one, single) thread.
All of this wouldn't be an issue if the flow's lambdas wouldn't access shared mutable state1 located outside. Let's have a look:
Let's start with collect because that's easy to fix. collect
never processes the flow's current value (it
). Instead it calls the callback with editableUserInputState.text
. Another coroutine could have changed that after snapshotFlow
emitted the flow's original value so the callback would be called with the wrong value. In this specific case it could be called with "Choose Destination"
, which would otherwise be impossible because that would have been filtered out by the preceeding filter
.
This can be easily fixed. Pass it
instead:
.collect { currentOnDestinationChanged(it) }
filter accesses editableUserInputState.isHint
. The isHint
property can change any time. That means when another coroutines manages to slip in between the execution of snapshotFlow
and filter
it could modify editableUserInputState
so the isHint
property now returns something else.
I see three ways to fix this:
Remove the filter altogether and replace collect
with:
.collect{
if (!editableUserInputState.isHint)
currentOnDestinationChanged(editableUserInputState.text)
}
This still doesn't access the flow's current value but at least the evaluation of isHint
will now always match what is sent to the callback.
Make sure the flow contains all relevant information so no access to the outside is needed:
snapshotFlow { editableUserInputState.text to editableUserInputState.isHint }
to
is a special Kotlin syntax to create a Pair
. Where snapshotFlow previously returned a Flow<String>
, this returns a Flow<Pair<String, Boolean>>
now: The first value of the Pair contains the text, the second isHint. The following flow operations must be adapted accordingly:
.filter { !it.second }
.collect { currentOnDestinationChanged(it.first) }
As you can see, editableUserInputState
isn't accessed anymore. So whatever another coroutine running inbetween does to that object, it has no effect on the flow operations.
Update the Material library2. That comes with a new TextField
that you can use to replace BasicTextField
in CraneEditableUserInput. TextField
comes with built-in support for hint (called placeholder) and the new version also supports using a dedicated state object called TextFieldState. Use rememberTextFieldState()
to obtain an instance and pass it as the first parameter.
You can then hoist the state as usual and since you don't have to handle the hint anymore the snapshotFlow
would now look like this:
LaunchedEffect(textFieldState) {
snapshotFlow { textFieldState.text }
.collect { currentOnDestinationChanged(it.toString()) }
}
1 as in Shared mutable state, unrelated to the MutableState
of Compose.
2 Since the Material library is managed by the Compose BOM you would need to update that to at least 2024.11.00
. At the time of writing that is still in alpha so you would need to change the Compose BOM in the module-level build.gradle to:
def composeBom = platform('androidx.compose:compose-bom-alpha:2024.11.00')
Note that the code lab at the time of writing still uses the old Material 2 library (androidx.compose.material:material
). In a real application you would want to use the more modern Material 3 (androidx.compose.material3:material3
).