My composable has a `LaunchedEffect` (that creates...
# compose
r
My composable has a
LaunchedEffect
(that creates a toast), keying on a state variable (via
by x.collectAsState()
). However, when the state is transient i.e. it changes relatively quickly from state A to state B, the
LaunchedEffect
is not triggered at all for either state A or B. Its a login screen and the user state transitions from a user login error to an anonymous user pretty quickly. I can make some changes to how the state is changed to avoid thus, but I'd like to understand what is going on here.
d
Fairly certain the docs of
LaunchedEffect
say not to use it like this.
😕 1
If you want to do something like a toast, you should
rememberCoroutineScope()
and
launch
.
d
Yeah (I thought that was clear) don't call it directly in the composable function lol, call it in a side effect function like
onClick = { scope.launch { ... } }
.
a
All of the above, yes. 🙂 If you'd like some more details about what's going on/what Compose is doing in your case, can you post your code that creates the LaunchedEffect? By the sound of it, this code is confusing observable state vs observable events.
r
Thanks for letting me know about Snackbar. I don't yet understand the ScaffoldState and SnackbarHostState concepts, so I don't yet know how this helps me. My naiive attempt to use it instead of Toast has not improved matters.
Fairly certain the docs of LaunchedEffect say not to use it like this.
Ok, I'm new to compose so please be more specific. As I understood, a snackbar should be triggered in a
LaunchedEffect
-- in fact, I'm trying to use it pretty much exactly like this example: https://developer.android.com/jetpack/compose/lifecycle#launchedeffect.
By the sound of it, this code is confusing observable state vs observable events.
That's again possible, but again I'm using it pretty much like the example above. My state has an error when the login fails, and then soon after the error is cleared from the state. Here is the code -- I've changed it to use
Snackbar
instead of toast, but it is not working:
Copy code
@Composable
fun LoginSplash(userViewModel: UserViewModel) {
  // viewState is a StateFlow
  val userState by userViewModel.viewState.collectAsState()
  val scaffoldState = rememberScaffoldState()

  val errorReason = userState.errorReason
  if (errorReason != null) {
    println("--0-0-000-0- doing snackbar with reason=$errorReason")
    LaunchedEffect(scaffoldState.snackbarHostState) {
      scaffoldState.snackbarHostState.showSnackbar(messageFrom(errorReason), duration = SnackbarDuration.Long)
    }
  }

  ... other view stuff down here ...
}
The logs show:
Copy code
D/UserStateTransformer: UserStateTransformer: BackendStateModel userState changed: state=UndefinedUser(user=dev.gitlive.firebase.auth.FirebaseUser@cef8d7, reason=ERROR)
I/System.out: --0-0-000-0- doing snackbar with reason=ERROR
... a few tens of milliseconds later...
D/UserStateTransformer: UserStateTransformer: BackendStateModel userState changed: state=com.xacoach.xascore.shared.api.AnonymousUser@27940e4
If I delay the state change to
AnonymousUser
then things worked fine when I was using toast, but now that I've switched to Snackbar even that doesn't work any more -- nothing shows at all either way. I'm entirely sure I'm missing something quite basic, but I don't know what it is.
Ah well I'm not actually using a Scaffold. Duh.
Ok, so now I've got Snackbar working, the behavior is exactly the same as when I was using Toast -- it works if there is a delay between the state changes, but if the state changes happen quickly, it does not.
a
as written that will only show the first non-null
errorReason
unless you recompose with a
null
in between to remove the
LaunchedEffect
r
I am doing that -- the AnonymousUser state has a null errorReason.
a
you likely want to pass
errorReason
as an additional key parameter to
LaunchedEffect
, so that if
errorReason
changes the old show is cancelled and it will show the new reason
r
My original code had that as the key parameter, without the
if
check surrounding it. I'm actually not sure why the
if
is required.
a
you want the
if
there so that there's no
LaunchedEffect
if there's no error to show
r
Fair enough. Adding errorReason as an additional key parameter did not improve matters. Its actually the first time that errorReason transitions to a non-null value that its not showing, so I don't think this has anything to do with multiple changes to errorReason.
It totally works fine if I add a delay before the subsequent state change, so it seems like a timing issue / race condition with recomposes.
a
so you're expecting this to show and queue all errors that happened even if they're no longer present?
r
I suppose so... the state backend has no special insight into when to clear the error. The error in this case is transient and only triggers the user message.
Is there a recommended pattern for that type of "queuing", or is my approach fundamentally flawed?
I mean, I can just keep the delay before the subsequent state change, but that just seems like a workaround with its own race -- what if the user's login succeeds before that delayed state change? Unlikely but possible.
I've tried wrapping the snackbar show in
NonCancellable
, but that doesn't solve the problem. As I said in my original post, the LaunchedEffect seems to not be executed at all, or it is but it is cancelled immediately.
a
depending on the rate of messages coming in, it may be any combination of those.
collectAsState
conflates incoming values, so new values that are emitted before recomposition will replace old as if they never happened. This is as intended, as state is always meant to be declarative and idempotent
👍 1
If you have a flow of values that you would like to queue for display instead, you can do it this way:
Copy code
LaunchedEffect(errorFlow, snackbarHostState) {
    errorFlow.collect {
        snackbarHostState.showSnackbar(messageFrom(errorReason), duration = SnackbarDuration.Long)
    }
}
this will exert backpressure on the
errorFlow
to provide queuing, presuming your flow supports that kind of backpressure
but if it's a
StateFlow
under the hood that you're breaking an error state off from, then the StateFlow is also going to conflate values in a similar way for the same reasons
r
I guess I could use a
SharedFlow
, which doesn't conflate as I understand it, for just the error event, along with a collect for the snackbar.
Thanks for your help will play with it a bit more later.
👍 1
I managed to get back to this and experiment a bit... it works well with a flow of `errorReason`'s in a
SharedFlow
(vs a
StateFlow
to avoid conflation), along with a manual
collect
inside
LaunchedEffect
. Note that using
collectAsState
on the new
SharedFlow
also did not work, as was expected due to the conflation by
collectAsState
, nor did using a manual collect on the existing
StateFlow
. I wonder if it might be valuable to abstract this complexity with a Compose function like
fun <T> SharedFlow<T>.collectAsUnconflatedState()
. I would imagine acting on events like this is not an unusual requirement.