Skip to content

Instantly share code, notes, and snippets.

@markerikson
Created April 7, 2019 03:04
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save markerikson/6601bd8390471791e4b958efc640a094 to your computer and use it in GitHub Desktop.
Save markerikson/6601bd8390471791e4b958efc640a094 to your computer and use it in GitHub Desktop.
Discussion: Batching behavior in React-Redux v7

[10:22 PM] acemarke : @noj yo. so picking up the conversation from Twitter
[10:22 PM] acemarke : you were asking what exactly the use of unstable_batchedUpdates() does and how that comes into play
[10:22 PM] noj : hey, so i was wondering how the batching story works w/ react redux v7
[10:22 PM] noj : yes
[10:22 PM] noj : so a wrinkle is i use redux-saga
[10:22 PM] acemarke : and how that relates to things like dispatching in sagas
[10:22 PM] acemarke : yup
[10:22 PM] noj : mostly of the form
[10:23 PM] noj : i have a saga listenening for an action and doing some other actions as a side effect, or often making an rpc call and then some more actions
[10:23 PM] noj : i was wondering if a chain of side effects dispatches n actions
[10:23 PM] noj : if those will cause n mapstatetoprops calls
[10:23 PM] noj : or if the batching will essentailly debounce that to once per paint
[10:24 PM] noj : for me it seems like running a reducer a couple of tiems in a frame is ok but making react render multiple times per frame isn't needed
[10:24 PM] acemarke : yup
[10:24 PM] acemarke : terminology-wise - when you say "frame", are you meaning a JS event loop tick?
[10:25 PM] noj : sure
[10:25 PM] noj : from looking at the profiler (in my app, which is complicated, i should make a smaller example) it seems like i get a bunch of notifynestedsubs (etc) when in a single larger function block
[10:25 PM] noj : which seems like everything thats happening in that event loop tick
[10:26 PM] acemarke : just to check, which version of React-Redux are you using atm?
[10:26 PM] noj : v7 b 1
[10:26 PM] acemarke : ah, cool :)
[10:26 PM] acemarke : and have you read through either React-Redux issue 1177, or my post on "The History and Implementation of React-Redux"?
[10:26 PM] noj : i was on 6 then went back to 5 and am now testing 7
[10:26 PM] noj : yeah (great post by the way!)
[10:27 PM] acemarke : thanks :)
[10:27 PM] acemarke : okay. so lemme see if I can focus on just the saga and batching aspects here
[10:27 PM] noj : it just wasn't clear to me if the internal use of batching would make the setstate that all the connected components would effectively do this
[10:27 PM] noj : and therefore any external action stuff (sagas) would get this behavior, or if
[10:27 PM] noj : it only applies to say mapstatetodisaptch dispatches or something
[10:29 PM] acemarke : so, first thing to understand: the React unstable_batchedUpdates() API specifically works like this:

unstable_batchedUpdates(() => {  
    somethingThatCausesAStateUpdate();  
    somethingThatCausesAStateUpdate();  
})  

[10:29 PM] acemarke : normally, if I do something like this.setState() outside of React completely (like in a promise callback), each invocation of this.setState() basically causes a synchronous re-render
[10:30 PM] acemarke : however, multiple updates inside of batchedUpdates() instead cause all the updates to be queued and batched together into a single render pause afterwards
[10:30 PM] acemarke : and in fact, React already wraps all your event handlers with batchedUpdates(), like click handlers - that's how all the batching behavior you may have heard about happens
[10:31 PM] acemarke : so with that in mind
[10:31 PM] acemarke : moving on to the Redux side of things
[10:32 PM] acemarke : we'll ignore v6 for now, because that behaves differently, and focus on v5 / v7
[10:33 PM] acemarke : both of those have each connected component instance subscribe to the store separately
[10:33 PM] acemarke : and when any action is dispatched, all store subscribers are called synchronously as the last step inside dispatch()
[10:34 PM] acemarke : regardless of whether any state updates occurred or not
[10:34 PM] acemarke : (which means that "signal"-type actions dispatched from sagas will indeed cause subscribers to be notified)
[10:34 PM] acemarke : now, the first thing the subscriber callbacks do internally is basically check to see if the root store state changed at all
[10:34 PM] acemarke : via memoization checks
[10:35 PM] acemarke : if the state did not change, they bail out immediately and return the previous child props result
[10:36 PM] acemarke : and they do not force React to re-render those components
[10:36 PM] acemarke : so, while there is a bit of a cost there, dispatching actions that don't cause state updates is not overly expensive
[10:36 PM] noj : this is root state and before mapstatetoprops right
[10:36 PM] acemarke : yes
[10:36 PM] noj : so a pure action causing another action typ thing w/ no reducer changes
[10:36 PM] noj : k
[10:37 PM] acemarke : the next factor in both v5 and v7 is that they rely on use of a custom Subscription class to actually cascade store update notifications down the tree, in order to ensure top-down updates and that components always have the latest props available when they run mapState
[10:37 PM] acemarke : so only the upper few components wind up subscribing to the store directly - nested connected components are technically subscribed to their nearest connected ancestor
[10:37 PM] acemarke : especially in v5
[10:37 PM] noj : this is notifynestedsubs
[10:37 PM] acemarke : yup
[10:37 PM] noj : k
[10:38 PM] acemarke : so the idea is, for any given connected component, wait until we know it either doesn't need to update at all, or it has updated, and then tell its descendants it's safe to run their own subscription callbacks
[10:39 PM] acemarke : in v5, notifyNestedSubs ran in componentDidUpdate. In v7, it's a useLayoutEffect(). Both of those run synchronously at the end of the React "commit phase", and any further React state updates that are caused in that phase will be executed synchronously somewhere in that process (still not quite clear if it's immediately synchronously, or added up and run synchronously afterwards)
[10:40 PM] acemarke : so yeah, all the notifyNestedSubs() and nested update work will happen synchronously, ideally
[10:40 PM] acemarke : so here's where it gets tricky, and interesting
[10:40 PM] acemarke : in v5, we didn't batch anything
[10:41 PM] acemarke : if you were dispatching an action in a React event handler, it would batch updates in general
[10:42 PM] acemarke : and remember that each connected component is calling setState() or an equivalent separately if it needs to update
[10:42 PM] acemarke : but given the update patterns, I'm pretty sure that only the first level of connected components would really get batched in those cases
[10:42 PM] acemarke : and of course, if you dispatched an action outside a React event handler, there was no batching at all
[10:43 PM] acemarke : and my intuition says that that may have actually wound up resulting in lots of little synchronous micro-renders or something
[10:43 PM] noj : this is why this closure based batching seems so weird to me
[10:43 PM] acemarke : ie, one for each connected component that updated
[10:43 PM] acemarke : it has to do with how React is implemented
[10:43 PM] noj : vs it being like, patchign dispatch for the event loop or somethign
[10:43 PM] acemarke : it's not dispatch that's the issue
[10:43 PM] acemarke : it's React's own update machinery
[10:43 PM] acemarke : I suspect the implementation is roughly like
[10:44 PM] noj : i guess its setstate itself
[10:44 PM] acemarke :

let areWeBatchingUpdates = false;  
  
export function unstable_batchedUpdates(callback) {  
    areWeBatchingUpdates = true;  
    callback();  
    areWeBatchingUpdates = false;  
    renderPendingUpdates()  
}  

[10:45 PM] acemarke : so in v7
[10:46 PM] acemarke : I modified our own internal Subscription class to use unstable_batchedUpdates, like this:

      batch(() => {  
        for (let i = 0; i < listeners.length; i++) {  
          listeners[i]()  
        }  
      })  

[10:46 PM] acemarke : in other words, all of the nested connected components directly below this should have their updates batched together into one render pass
[10:47 PM] noj : cause they sync call setstate inside of this call
[10:47 PM] acemarke : yup
[10:47 PM] noj : (if they need to)
[10:47 PM] acemarke : so, all Redux dispatches should benefit from this in v7, in terms of how many total update passes React has to run internally in response to a single dispatch
[10:49 PM] acemarke : oh, and the other factor here
[10:49 PM] noj : but this applies to a single action firing right
[10:49 PM] acemarke : yeah
[10:49 PM] acemarke : in v5, the top few connected components were still subscribed to the store directly
[10:49 PM] noj : so before its possible one action => setstate a bunch of times = a bunch of renders which should go away
[10:49 PM] acemarke : in v7, we use this Subscription class directly inside <Provider> - so no components are subscribed to the store directly. This batching applies right away to the top components
[10:50 PM] noj : but several actions firing in a single event loop you get batch called multiple times which doesn't help cause they're isolated
[10:50 PM] acemarke : right
[10:50 PM] noj : k
[10:50 PM] acemarke : which is where import {batch} from "react-redux" comes in
[10:50 PM] noj : yeah
[10:50 PM] noj : a problem with using that is how my saga stuff is architected
[10:50 PM] acemarke : yeah, and that I'm not sure batch() will help with
[10:50 PM] noj : its sort of a signals / slots thing
[10:51 PM] noj : so its like one action 'login' might have 10 different sagas that listen
[10:51 PM] noj : and do stuff
[10:51 PM] noj : so its hard to coalesce that
[10:51 PM] acemarke : simply because each yield put() is going to involve returning to the saga middleware, not something where you can execute multiple dispatches in one callback
[10:52 PM] noj : yeah for me batching would be ideally more like rAF
[10:53 PM] noj : its like a bucket of dispatches could be aggregated per frame and executed
[10:53 PM] acemarke : I think that's how React will behave in Concurrent Mode
[10:53 PM] noj : essentially decoupling the store from reacts update cycle
[10:53 PM] noj : so its not sync
[10:53 PM] acemarke : on the other hand, Concurrent Mode is likely to cause "Bad Things" (TM) to happen with React-Redux, soooo....
[10:53 PM] noj : aka store can update at whatever rate but react is max 60 FPS
[10:54 PM] noj : ok, thanks so much for this explanation! i really did enjoy the roadmap issue for v7 and everything that went into that. thanks so much for your work!
[10:56 PM] acemarke : sure :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment