Marc Knaup
03/18/2021, 3:05 PMStateFlow
in a Kotlin/React project and noticed an interesting nuance that may make it unsuitable for providing state in React-like projects - and maybe others. Am I missing something or do I have an error in my thinking here?
In my case the problem crops up in the sign-in process.
To simplify, there are just two pages:
- The MembershipPage
shows information about the signed-in user. If no user is signed in, redirect to SignInPage
.
- The SignInPage
allows the user to sign in. If a user is already signed in, redirect to MembershipPage
.
A single UserController
component wraps these (and other) user-related pages, keeps track of the signed-in user and provides that value to pages, and performs the redirects if needed.
Various React components keep track of the signed-in user by accessing StateFlow.value
when first rendering the component, and collect StateFlow
emissions of new values to account for changes.
Now when the user signs in the following happens:
1. Send credentials to server.
2. Server responds with OK and user object.
3. StateFlow
value is set to signed-in user (flow.value = user
).
4. SignInPage
redirects to MembershipPage
. (user has signed in successfully)
5. UserController
redirects to SignInPage
. (not signed in)
6. SignInPage
redirects to MembershipPage
. (already signed in)
7. UserController
redirects to SignInPage
. (not signed in)
8. <repeats a few times>
9. SignInPage
redirects to MembershipPage
. (already signed in)
10. MembershipPage
is presented.
There’s a redirect cycle that gets broken automatically after a short time.
What’s happening here?
After a successful sign-in the `StateFlow`’s value
is set to the signed-in user. From that moment on flow.value
returns the signed-in user that was just set. The `StateFlow`’s emission of that new value however is not immediate and takes a while.
During steps 4 through 8 above there is an inconsistent state in the React components. Those that happen to use StateFlow.value
acknowledge that a user is signed in. Those that wait for the `StateFlow`’s emission of a new value still consider no user to be signed in. Depending on whether a component is first rendered (accessing .value
) or rerendered (value depends on .onEach/.collect
) the behavior is different.
So by using StateFlow
the system inadverently depends on two different sources of truth for the signed-in state. That’s because the return value of .value
and StateFlow
emissions of new value are not synchronized.
I’m not sure if that’s intentional behavior of StateFlow
. State should have a single source of truth and StateFlow
itself is inconsistent here.
One potential solution would be to change how StateFlow
works. For example by having .value
to not return a new value until the emission of the new value has begun. There’s still a potential for race between different contexts but at least in synchronous environments like JS that should be irrelevant.
There are two possible approaches to use StateFlow
as-is as a single source of truth:
(1) Rely on .value
only.
(2) Rely on value emissions only.
(1) doesn’t work because components need to know when the value changes in order to re-render. Otherwise the UI would get out of sync with the state.
(2) doesn’t work because components need to access the current value synchronously at the time of initial render. Otherwise the UI would always have to briefly display a loading state while waiting for the initial emission even though that value is already available synchronously.
As a compromise I could always rely on .value
for accessing the current value and use emissions only for triggering a re-render. An inconsistency still remains however. Components aren’t notified immediately when the state they depend on changes. They either render a state that’s potentially newer than the state they were last notified about, or they render a state change too late, and they may render more often than needed.
If the SignInPage
executes some logic as a result of a successful sign-in it does so under the assumption that the user is signed in now. Other components however still assume that no user is signed in. No re-render is pending for them because there’s no notification about a change in state yet. Hence the UI is temporarily inconsistent.
I don’t think that this subtle inconsistency between .value
and emissions only affects Kotlin/React projects. It may also affects other projects which rely on a mix of accessing .value
and emitted values.
If it’s intentional then it should be communicated prominently in the documentation. However for me it would defeat the purpose of a StateFlow
for managing state because it’s not a single source of truth.
I could also write a wrapper that may use a StateFlow
under the hood but synchronizes the current value with the emissions. But that’s just reinventing what I assume StateFlow
is supposed to provide in the first place.
What are your thoughts on that?elizarov
03/18/2021, 3:29 PMStateFlow
being an asynchronous source of state. It requires a different mental model as opposed to similar synchronous callback-based APIs.Marc Knaup
03/18/2021, 3:29 PM.value
synchronous then? It should also be asynchronous.Marc Knaup
03/18/2021, 3:30 PM.setValue(value)
and a non-suspending offerValue
.Marc Knaup
03/18/2021, 3:31 PMelizarov
03/18/2021, 3:31 PMMarc Knaup
03/18/2021, 3:32 PMStateFlow
API (and current docs).elizarov
03/18/2021, 3:32 PMelizarov
03/18/2021, 3:33 PMMarc Knaup
03/18/2021, 3:34 PMMarc Knaup
03/18/2021, 3:35 PMsetValue(…)
that won’t return until emissions are starting [or value was overwritten again]? That would probably solve it for React.elizarov
03/18/2021, 3:41 PMelizarov
03/18/2021, 3:42 PMMarc Knaup
03/18/2021, 3:43 PMelizarov
03/18/2021, 3:43 PMelizarov
03/18/2021, 3:43 PMMarc Knaup
03/18/2021, 3:44 PMelizarov
03/18/2021, 4:06 PMelizarov
03/18/2021, 4:07 PMMarc Knaup
03/18/2021, 4:08 PMState<…>
normalizes the behavior for Compose.Marc Knaup
03/18/2021, 4:08 PMelizarov
03/18/2021, 4:09 PMMarc Knaup
03/18/2021, 4:10 PMelizarov
03/18/2021, 4:10 PMelizarov
03/18/2021, 4:11 PMMarc Knaup
03/18/2021, 4:12 PMMarc Knaup
03/18/2021, 4:12 PMhistory.replace()
etc.Marc Knaup
03/18/2021, 4:12 PMelizarov
03/18/2021, 4:12 PMMarc Knaup
03/18/2021, 4:13 PMelizarov
03/18/2021, 4:13 PMMarc Knaup
03/18/2021, 4:14 PMStateFlow
emissions because they’re async.Marc Knaup
03/18/2021, 4:14 PM.value
as source of truth and emissions just to trigger rerenders. Not ideal but better than the issue I hadelizarov
03/18/2021, 4:15 PMSignInPage
redirects to MembershipPage
. (user has signed in successfully). What is the source of this decision?
UserController
redirects to SignInPage
. (not signed in) What is the source of this decision?elizarov
03/18/2021, 4:15 PMMarc Knaup
03/18/2021, 4:16 PMSignInPage
was basing it as a result of the successful authentication which has set .value = user
and thus .value
reports “signed in”.
Subsequent renderings of SignInPage
saw .value
is set to a user so triggered subsequent redirects.
The redirect for MembershipPage
was coordinated by UserController
which was mounted already, so it had to rely on Flow emissions to notice an update of the `StateFlow`’s value
elizarov
03/18/2021, 4:17 PMMarc Knaup
03/18/2021, 4:18 PMMarc Knaup
03/18/2021, 4:19 PMelizarov
03/18/2021, 4:21 PMMarc Knaup
03/18/2021, 4:23 PM.value
(initial render) and emission-based value
.elizarov
03/18/2021, 4:23 PMoriginalState.map { mapStateToUrl(it) }.onEach { redirectTo(it) }.launchIn(scope)
. The flows do not really guarantee glitch-freedom (it is hard and computationally expensive) but should work for your case.Marc Knaup
03/18/2021, 4:25 PMSignInPage
only knows in isolation what URL it has to redirect to after a successful sign-in. That URL is typically dynamic too.
I was thinking about waiting for the emission of my .value
that I’ve just set.
Like
flow.value = user
val user = flow.first()
redirect()
But no idea if that’s reliable.elizarov
03/18/2021, 4:25 PMMarc Knaup
03/18/2021, 4:26 PMMarc Knaup
03/18/2021, 4:27 PMsetState
if/when state changes and I only change state if/when I call setState
.elizarov
03/18/2021, 4:27 PMflow.value = user
flow.first { it == user } // wait for this user value!!!
elizarov
03/18/2021, 4:28 PMelizarov
03/18/2021, 4:28 PMelizarov
03/18/2021, 4:29 PMawaitValueUpdate()
specifically designed for it. (ah... the problem of finding a good name for it)Marc Knaup
03/18/2021, 4:29 PMMarc Knaup
03/18/2021, 4:30 PM.value = user
3. await emissions (start is sufficient)
4. redirect (= state change, rerender now queued)
5. rerender (all emissions should have happened here, don’t they?)Marc Knaup
03/18/2021, 4:31 PM.value
which returns new state before it notified about the change.elizarov
03/18/2021, 4:31 PMMarc Knaup
03/18/2021, 4:32 PMMarc Knaup
03/18/2021, 4:32 PM.lastEmittedValue
and .emitAndWait(…)
.elizarov
03/18/2021, 4:33 PM.emitAndWait()
might be a good name. but .value
is the last emitted value....elizarov
03/18/2021, 4:35 PM.emitAndWaitReceived()
might be even clearerMarc Knaup
03/18/2021, 4:35 PM.value
being different from what was last emitted.Marc Knaup
03/18/2021, 4:36 PMelizarov
03/18/2021, 4:36 PMemit(value)
call makes the value emitted
2. onEach { value -> ... }
makes the value receivedMarc Knaup
03/18/2021, 4:36 PM.lastReceivedValue
thenelizarov
03/18/2021, 4:37 PM.lastReceivedValue
with multiple subscribers. They might be receiving at a different rate.Marc Knaup
03/18/2021, 4:37 PMStateFlow
?Marc Knaup
03/18/2021, 4:38 PMelizarov
03/18/2021, 4:38 PMelizarov
03/18/2021, 4:38 PMMarc Knaup
03/18/2021, 4:39 PM.value
as state and collect for updates, then that’s out-of-sync already.Marc Knaup
03/18/2021, 4:39 PMelizarov
03/18/2021, 4:39 PMelizarov
03/18/2021, 4:40 PMuseState
code you've shown. It is Ok that it will take some time to see new value and only then update itself.elizarov
03/18/2021, 4:42 PMuseState
here and there in my react components to render something based on a value, then the worst thing that can happen is that it'll wait for a few ticks (based on how JS queues are internally prioritized) before it renders an update.Marc Knaup
03/18/2021, 4:44 PMStateFlow
new value without being notified about it.
Component B re-renders a little later because it got notified about StateFlow
value change.
So for a brief period A and B don’t represent the same value of the StateFlow
.elizarov
03/18/2021, 4:45 PMMarc Knaup
03/18/2021, 4:47 PMMarc Knaup
03/18/2021, 4:48 PMMarc Knaup
03/18/2021, 4:49 PMStateFlow
.elizarov
03/18/2021, 4:49 PMelizarov
03/18/2021, 4:49 PMMarc Knaup
03/18/2021, 4:51 PMGlobalScope
right now and there’s no GlobalScope.immediate
. How do I use the immediate dispatcher?elizarov
03/18/2021, 4:51 PMelizarov
03/18/2021, 4:52 PMDispatchers.Main.immediate
Marc Knaup
03/18/2021, 4:53 PM