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 valueelizarov
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.immediateMarc Knaup
03/18/2021, 4:53 PM