Bug: is the current `useSyncExternalStore` batching & flushing behaviour intended?

This issue has been created since 2022-09-05.

React version: 18

Link to code example:

CodeSandbox

The expected behavior

Let's assume we have a increment function that first increments a local value, then a uSES value and then another local value like this:

function increment() {
    setState1((x) => x + 1);
    setUsesState((x) => x + 1);
    setState2((x) => x + 1);
  }

Now, there would be two ways this could behave that would be "intuitive for me":

  1. everything is batched: [state1, usesState, state2] goes from [0,0,0] to [1,1,1]
  2. it batches until the "sync update" flushes the current batch: [state1, usesState, state2] goes from [0,0,0] to [1,1,0] to [1,1,1]

The current behavior

Now, actual behaviour is different in React 18, depending on the "mode" React is currently in.

  1. in an event handler, everything is batched [0,0,0] to [1,1,1] - no problem here
  2. outside an event handler, the uSES setter is flushed first, then the local state changes are batched. [0,0,0] becomes [0,1,0] becomes [1,1,1] - this is very unintuitive for me.
  3. even inside a manual call wrapped in unstable_batchedUpdates, we go [0,0,0] -> [0,1,0] -> [1,1,1]

Point 3 means that there is actually no way to even manually batch an update by uSES - but looking at point 1, React sometimes does so internally.

It seems that even in the non-batched situations, React does some batching: Calling setUsesState twice before calling setState2 will not lead to a [0,0,0] -> [0,1,0] -> [0,2,0] -> [1,2,1] situation, but to [0,0,0] -> [0,2,0] -> [1,2,1]

Up until now we had assumed that uSES would always behave like in 1., and we were only made aware of this by bug reports on react-redux.

Is this intended behaviour or a bug?

There might be some high priority update thing with a transition that I am missing here though - but either way this feels very breaking from older behaviour to me - and yes, I know that batchedUpdates has the unstable prefix ;)

dai-shi wrote this answer on 2022-09-05

fwiw, startTransition seems to make the behavior more consistent.
https://codesandbox.io/s/uses-behaviour-react18-forked-pmq3o1
so, the onClick handler would be an exception with higher priority.

(btw, logMethod is a nice hack.)

Andarist wrote this answer on 2022-09-05

Related issue: #24831

phryneas wrote this answer on 2022-09-05

fwiw, startTransition seems to make the behavior more consistent. codesandbox.io/s/uses-behaviour-react18-forked-pmq3o1 so, the onClick handler would be an exception with higher priority.

Even with the onClick={() => startTransition(increment)}, I am seeing [0,0,0] -> [0,1,0] -> [1,1,1] - or am I missing something?

dai-shi wrote this answer on 2022-09-05

I meant only the in-consistent behavior is bare onClick. I didn't mean which is correct or expected.

sebmarkbage wrote this answer on 2022-09-06

It's because useSyncExternalStore always has to be flushed at the highest priority where as other updates can be delayed. It can't be delayed because of the "Sync" part which is a compromise to using this API since it trades preserving internal consistency with the external store by compromising the ability to delay it. So it can't be batched but it also can't be time sliced and deprioritized.

You can regain consistency by flushing other updates together with this one by using flushSync(...) but then you're compromising by making all other updates not delayed neither.

If it was batched, which is basically what you'd get by manually subscribing without using it, you lose other types of consistency due to using a mutable backing store.

sebmarkbage wrote this answer on 2022-09-06

In React <=17 the default for setState was sync - you could opt-in to batched with batchedUpdates.

In React 18 the default is batched - you can opt-out to sync with flushSync.

useSyncExternalStore is always sync to preserve the consistency with other mutable data.

Andarist wrote this answer on 2022-09-06

Hm, while priority-based updates are probably a powerful feature - it's really hard to grasp how this works in edge cases like this. It feels like there should be a way to somehow "join" those updates without resorting to flushSync. In this case, it would be quite convenient to change the priority of the update with the default priority if there is already an ongoing "sync" update. Correct me if I'm wrong but the uSES update is still not exactly synchronous - all updates coming from that store are batched together and more often than not it is desirable to flush other updates with those. Part of the problem is that people usually won't even interact with uSES directly but rather through a library. In those situations, it's even harder to notice that one might deal with such a discrepancy in flushed updates.

sebmarkbage wrote this answer on 2022-09-06

The interesting thing about these things is whether it's observable behavior or a perf consideration. If the useEffects observing the rendering or painting by the browser, while still being implemented idiomatically, observe this difference in a negative way. From what I can tell by the reports, these are just a perf concern rather than a semantic concern.

The perf concern might be valid though.

I don't see it being possible to delay useSyncExternalStore updates beyond an event loop. It opens up a lot more complexity which this feature isn't really meant to address.

However, grouping sync, discrete and default updates into a single lane and flushing them at the highest priority available opportunity is consistent with the model. In other words, flushing useSyncExternalStore and other updates in the same event loop together in the next tick would be valid.

The downside of that approach is that it means that you might be relying on batching of many small updates. That's the idea that you shouldn't have to think about throttling and stuff. That should be automatic in the model. But just adding a call to something that uses sync external store might deopt the whole app and causing those to suddenly become a perf issue. That might not be a huge issue though.

In particular setState in useEffect is tricky because you don't want to cause these multiple synchronous passes, but setState in useEffect is so trick.

Ofc, on the flip side, rendering twice might also be an issue.

It would certainly simplify the model if it was a single lane.

Andarist wrote this answer on 2022-09-06

The interesting thing about these things is whether it's observable behavior or a perf consideration.

The first minimal~ repro case in this thread showcases this problem through an observable behavior (not a perf issue)

However, grouping sync, discrete and default updates into a single lane and flushing them at the highest priority available opportunity is consistent with the model. In other words, flushing useSyncExternalStore and other updates in the same event loop together in the next tick would be valid.

Yeah, I was asking about this - not the other way around. It's good to know that this wouldn't break the model.

rickhanlonii wrote this answer on 2022-09-06

all updates coming from that store are batched together and more often than not it is desirable to flush other updates with those

What types of updates are you thinking of? They would need to be updates that are not already marked with a priority, right?

Andarist wrote this answer on 2022-09-06

A user has reported to us that something like this (conceptually) wasn't flushed together:

// executed asynchronously somehow, not called from the event handler
function increment() {
    setState1((x) => x + 1);
    setUsesState((x) => x + 1);
}

This led to the situation in which useEffect in the parent couldn't "see" the updated DOM from the child because the child was dependent on that "default" update (the setState1 here) while the parent was depending on the "sync" update from the sync store (the setUsesState here).

So if I interpret the question correctly - then yes, that other update was not explicitly marked with any priority.

phryneas wrote this answer on 2022-09-06

Even with only one component, this could lead to useEffect side effects for combinations of local state and global state that should - from a user's perspective - be impossible.

rickhanlonii wrote this answer on 2022-09-06

I mean, where was increment called?

What would you expect if it was called as:

startTransition(() => {
  increment();
})
Andarist wrote this answer on 2022-09-06

One of the user reports that we have received can be found here. In this case, there is a handleFetch function that calls regular setState but it is itself called by XState when a promise resolves. XState also changes its state when that promise gets resolved and that state is using uSES.

What would you expect if it was called as:

I'd expect both updates to be flushed together at some point. I wouldn't expect the uSES update to be flushed immediately, despite it being "sync". Since both updates are wrapped with startTransition I don't expect the uSES update to be reflected on the screen immediately.

sebmarkbage wrote this answer on 2022-09-13

We discussed with the team and we agreed that it makes sense to change this. Now it's just a matter of implementing it and when someone can get around to it.

While React treats these as separate lanes, the programming model only has "Sync" and "Transition" so it makes sense to treat these all as Sync and flush them all together at the last possible opportunity but no later than the earliest heuristic.

If something is wrapped in startTransition only the setStates will be delayed, and any uSES will be flushed early. I thought that case was even a warning? Maybe we should add back the warning.

markerikson wrote this answer on 2022-09-13

Thank you!

More Details About Repo
Owner Name facebook
Repo Name react
Full Name facebook/react
Language JavaScript
Created Date 2013-05-24
Updated Date 2022-09-26
Star Count 195293
Watcher Count 6655
Fork Count 40426
Issue Count 1113

YOU MAY BE INTERESTED

Issue Title Created Date Comment Count Updated Date
SEO should return chain logo? 2 2022-03-28 2022-09-13
Move specialized constant-time functions to their respective modules 0 2022-09-13 2022-09-19
TLS 1.3 mbedtls_ssl_get_psk_to_offer leaves data un-initialized 2 2022-09-14 2022-09-19
Not Working on my Windows 11 - Windows11DragAndDropToTaskbarFix 2 2022-02-16 2022-09-26
Some instances of explorer.exe crashing when I try to drag and drop a video file onto the taskbar 5 2022-02-23 2022-09-12
cryfs-unmount fails if path contains spaces 3 2021-02-06 2022-08-10
Kuboard Spray密码忘记,如何重置? 1 2022-04-18 2022-09-17
Systemd service cannot be started - beeshome is read only 10 2021-10-18 2022-09-22
Feedback: Works with KL50 filament bulbs firmware 1.1.13 1 2022-01-04 2022-09-23
Logs constantly repeating "fireCharacteristicUpdateCallback [Brightness]: Unable to call updateCallback" 2 2022-01-14 2022-09-03
Display per-thread messages 1 2021-11-11 2022-09-05
Network configuration problems 2 2021-11-14 2022-09-13
[Recipe] Format Unix timestamp 4 2021-06-05 2022-09-25
K8SSAND-1035 ⁃ Add k8s 1.22 to k8ssandra helm chart tests 0 2021-11-04 2022-09-13
Cannot delete contact after they leave a group 1 2022-09-11 2022-09-22
Encrypted content on persistant storage with forgettable keys 9 2022-09-08 2022-09-22
[iox-introspection-client] Error occurred while acquiring file lock named introspection 2 2022-03-09 2022-08-09
Too much slow 2 2021-07-20 2022-01-09
\Can't view output in Terminal 7 2021-06-25 2022-08-31
trojan-go 连接规范 1 2021-07-03 2022-01-11
How well will this handle Chinese? 1 2019-06-27 2022-09-13
Rendering 3 times on load every time 5 2020-11-24 2022-09-25
合上盖子后自自动唤醒了 10 2021-08-15 2022-01-22
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function 11 2020-04-15 2022-08-30
ENV should include update/merge!/replace methods 2 2021-12-02 2022-07-29
When lambda times out, no custom subsegments are seen 3 2022-08-12 2022-09-18
Extract code coverage from docker-based tests 0 2020-09-28 2022-09-13
Update documentation to mention that Cursor.execute eagerly downloads rows 11 2020-12-22 2022-09-05
[Feature] Fix double dumped JSON in serve dagnode 0 2022-03-18 2022-09-24
Launch Library 1 and Space Launch Now API replaced by Launch Library 2 2 2022-02-12 2022-08-13
.rbenv/versions/3.0.0/lib/ruby/3.0.0/set/sorted_set.rb:4:in `rescue in <top (required)>': The `SortedSet` class has been extracted from the `set` library.You must use the `sorted_set` gem or other alternatives. (RuntimeError) 1 2021-04-18 2022-09-05
Error by using watir-extensions-element-screenshot on Firefox 85.0.1 2 2021-02-11 2022-09-05
[🐛 Bug]: Ruby `send_keys` wrong for combination like `element.send_keys([:shift, 'a'])` after update to Chromedriver v98 4 2022-02-09 2022-09-14
[change] Invert upload/download on interfaces that are member of a bridge 2 2021-01-10 2022-08-10
sql-server INSERT performance is unacceptable for reasonably sized data imports. 0 2021-09-02 2022-09-25
Path semantics (general vs method-specific) 6 2021-02-23 2022-09-25
Access denied on expose.dev 0 2022-07-21 2022-09-14
Acquia Cloud Plugins 0 2021-11-09 2022-07-23
Bump sequelize from 4.39.0 to 5.15.1 in /nodejs/nodeSNS 0 2019-11-02 2022-08-09
MPI_LONG_DOUBLE causes MPI_ERR_ARG in MPI-IO on i386 3 2022-04-16 2022-09-22
kitty.actions#get_all_actions is returning a KeyError 9 2021-08-04 2022-08-21
Implement the decided information architecture 1 2021-08-26 2022-09-03
集群Leader逻辑是否存在问题?含义是什么(Is there a problem with cluster Leader logic? What does it mean) 7 2021-11-01 2022-09-24
Incorrect time formatting 0 2021-05-31 2022-09-25
How to override Plotly.js methods (downloadImage) 1 2021-01-05 2022-09-21
Bind mount for Rancher 0 2021-10-04 2022-08-10
Client Allocation Request for: 中科智云 2 2021-09-02 2022-09-18
Error: API responded with a 401 Unauthorized 7 2022-05-10 2022-09-17
[Bug Report][2.5.8] Random scroll when using v-select or autocomplete in multiple mode 6 2021-09-04 2022-08-30
There is a strange dividing line between two containers 2 2021-12-23 2022-09-25