While the public API intended for users to use is the scheduler
package, the reconciler currently
does not use scheduler
's priority classes internally.
ReactFiberScheduler
has its own internal "mini-scheduler" that uses the scheduler
package
indirectly for its deadline-capable scheduleCallback.
This is kind of a documentation of implementation details that I suppose will be gone by the end of the year, but what can you do.
ReactFiberScheduler
keeps a list of pending batches of updates, which it internally calls "work".
Each individual fiber in the tree is a "unit" on which a "unit of work" will be "performed".
Updates caused from inside a React render, lifecycle method/hook, or React-controlled event handler belong to an implicit batch.
Updates triggered from outside React are effectively a single-update batch unless it's inside the scope of one of the batching method wrappers, shown later.
Any update on any fiber traverses the entire tree mounted on the root (ReactDOM.render
or
createRoot
) where the update happened. React is very good at bailing out of fibers that are not
the subject of the update.
ReactFiberScheduler
has its own priority classes that are independent from the priority classes in
the scheduler
package.
In non-ConcurrentMode
, any and all updates are sync no matter what. The only difference is whether
they are batched or not.
When the fiber where an update is triggered is in ConcurrentMode
, there are 3 possible priority
classes:
deferred
(equivalent toscheduler.NormalPriority
):- 5000ms deadline, async
- This is the default priority for any update that does not otherwise have its own priority or deadline.
interactive
(roughly equivalent toscheduler.UserBlockingPriority
):- 150ms deadline, async
- in development builds it's 500ms to make slow interactions feel worse
- At most one
interactive
priority work may be scheduled.
sync
(equivalent toscheduler.ImmediatePriority
but "more immediate"):- sync (duh)
- unlike
ImmediatePriority
this won't involve thescheduler
at all and just immediately start work.
- unlike
- a
sync
update will skip over any pending non-sync
update, even if it has expired - is the default (instead of
deferred
) for updates triggered in the commit phasecomponentDid{Mount,Update}
anduseLayoutEffect
run effectively in a single batch belonging to this priority. Any updates they trigger will besync
.
- sync (duh)
Any update triggered directly during a render phase inherits the deadline of the current render
phase. However, because any one fiber's update is atomic, this part of the processing is synchronous
per fiber even in ConcurrentMode
.
In class components, this means any update caused in a lifecycle that runs before render
itself;
this includes setState
s called in the UNSAFE_
family and the lifecycles that derive state, which
are processed like a setState
call with function argument. All state updates are accumulated while
the lifecycle is invoked and then applied synchronously after each lifecyle method returns. Calling
setState
inside render
itself is either a guaranteed noop or a guaranteed infinite loop, unless
your render
is impure.
In function components, all state updates (i.e. invoking dispatchers from useState
or
useReducer
) that happen during the render function are accumulated into a queue during the render
pass. If the queue is non-empty, the component re-renders, and useState
/ useReducer
apply their
respective queued updates from the previous pass as they are reached; until a render pass produces
no further queued updates. The number of re-renders is currently limited to 50.
NOTE: this is not in any alpha but will be in 16.8.0: any function component that invokes any
hook will be double-rendered in StrictMode
, and this is outside the render pass loop described
above. Both the hooks and the components themselves must be pure. This also means that, whenever
useMemo
or useState
would invoke their callbacks, they will always be double-invoked. On mount,
the first useRef
object will always be discarded, and only the one from the second invocation will
persist.
useEffect
are all collected into a single independent batch, called the "passive effects", and run
inside a scheduler.scheduleCallback
with no deadline, queued right before the commit phase ends.
However, should any further React update happens, regardless of priority class or deadline, the
schedule will be canceled and all pending useEffect
s for the entire previous commit will be
invoked synchronously before any work starts. This happens even when calling setState
or a
useState
/ useReducer
dispatcher. If the value is a callback, the previous commit's pending
useEffect
s will all have been processed by the time the callback is invoked.
Any interactive
update forces the previous interactive
update, as well as any other outstanding
updates with a shorter remaining deadline than that to commit synchronously first before the new
interactive
update itself is calculated.
In other words, it converts the previous interactive
update, as well as all work that should
have expired by the time it expired into a single sync
batch.
These are the only cases I found where a non-sync
update may be, effectively, upgraded to sync
by the reconciler.
It seems to be intended that user generic code uses priority classes and the methods from the
scheduler
package instead of these.
However, sometimes it is needed to interact with React specifically, so ReactDOM exposes these
(mostly prefixed with unstable_
, just like scheduler
exports).
batchedUpdates
causes all updates triggered inside it to share the same deadline. In other words, they will all belong to the same unit of work, and will all be rendered and committed together.batchedUpdates
does not have its own priority class, instead the callback inherits the current one.batchedUpdates
can be nested; this merely merges the updates inside it with the outermost batch (batches are flattened).- Other batching methods are not implemented as, but behave as if their callbacks were themselves
wrapped in
batchedUpdates
. - If
batchedUpdates
does not otherwise inherit a specific priority class, it defaults todeferred
.
interactiveUpdates
is a batch that hasinteractive
priority. React synthetic event handlers run as aninteractiveUpdates
batch.- Remember again that at most one
interactive
priority work may be scheduled. Should anotherinteractive
batch be queued, the previousinteractive
work is synchronously committed.
- Remember again that at most one
syncUpdates
is a batch that hassync
priority. React will immediately render and commit all updates inside this batch, before thesyncUpdates
function returns.- In non-
ConcurrentMode
any kind of batching just behaves like this one. - Explicitly requesting
sync
priority duringrender
or a lifecycle method/hook (exceptuseEffect
specifically) is an assertion error. The only implicit batches where you are allowed to requestsync
priority areuseEffect
(notuseLayoutEffect
) and in an event handler, which is just aninteractiveUpdates
batch.
- In non-
If any useEffect
callback or destructor triggers a sync
update through either being in a
non-ConcurrentMode
tree, or by using syncUpdates
as mentioned above, there will be a sync
commit done before any new update can even begin evaluation. If the callback or destructer triggers
an async update instead, the deadline will be calculated as if the useEffect
had been invoked
synchronously when the previous update committed1.
If any work being processed that's not yet in the commit phase, be it interactive
or deferred
,
is interrupted by a higher priority work, all progress done so far is completely thrown out.
Anything done in the commit phase is always sync
or belongs to a cascading sync
batch so the
commit phase can never be interrupted.
After React commits the higher priority (shorter deadline) work, it will start or restart the next
higher priority work on top of the freshly committed state. This will typically be interactive
batches before deferred
batches, but if a particular deferred
batch has fallen too far behind
(i.e. its deadline is too close to expiry) it will run ahead of interactive
.
This means that any lifecycle or render method (function component body) not in the commit phase can
potentially be called multiple times per render. StrictMode
ensures that is always done at least
twice per render during development mode.
The only things called by React that are guaranteed to only be invoked once per React update are the
commit phase lifecycles (componentWillUnmount
, componentDidMount
, componentDidUpdate
,
useLayoutEffect
), passive effects (useEffect
) and, for completeness, event handlers.
There is a non-ConcurrentMode
hack to only invoke class component constructor
s once if the
component or another child or sibling sharing the same Suspense boundary suspends when the class is
being mounted. This does not apply to ConcurrentMode
and classes are also subject to multiple
construction if the update where they are being first mounted is interrupted by a higher priority
update.
These instances, regardless of mode, will be discarded without invoking componentDidMount
or
componentWillUnmount
if they are never part of a finished commit.
Note that that any update caused inside a scheduler.scheduleCallback
does not count as a batch
unless the update is itself wrapped in batchedUpdates
, interactiveUpdates
or syncUpdates
.
React currently is not aware of scheduler
tasks and only uses it as a requestIdleCallback
with a
timeout.
This also means that the behavior of state updates is different in subtler ways than I thought in
ConcurrentMode
than in non-ConcurrentMode
.
I will use the traditional setState
to represent an update but calls to ReactDOM.render
,
ReactRoot#render
, updates to useState
or useReducer
values also cause updates.
In non-ConcurrentMode
, a non-batched setState
will always commit synchronously no matter what.
Any batching of them will also commit synchronously, but as a single update that is computed on top
of the result of all of the setState
s combined.
In ConcurrentMode
, non-batched setStates
may or may not form incidental mini-batches, depending
on how busy the renderer is and on whether their deadlines get exceeded.
If the deadlines are not exceeded, the renderer will render and commit them one-by-one, stopping
whenever it is going to exceed its frame time budget which it receives from scheduler
. This can
happen even in the middle of processing a single batch. There is a cooperative yield point after
processing each individual fiber.
If there is an incomplete work, or any non-sync
batch is still remaining after the renderer
yields, another scheduler.scheduleCallback
is queued with the deadline of the batch that is
closest to expire. It normally uses a requestIdleCallback
-like mechanism, but if the batch is
already expired it will immediately queue a macrotask.
In other words, as long as nothing has expired, only the singular work, or batch with the deadline closest to expiration is worked on in a particular render + commit loop. This is why batching is important: it ensures the requested work shares the same deadline and thus belong to the same render + commit loop.
When the work loop is resumed, if there was another work queued with a shorter deadline than the current work, all non-committed work done so far is thrown out. The higher priority work skips ahead of the queue and is done in its own commit.
If the work loop is resumed because any pending work's deadlines got exceeded, similarly all non-committed work done so far is thrown out, but all work with expired deadlines is done together in a single batch. The renderer will still yield when it exceeds its frame time budget, but because it has already expired it will be immediately resumed2.
This can be catastrophic if there are a significant number of pending updates with deadlines spaced together just enough that none of them can finish in time before the next one. Each time the partial work is thrown out there will be even more work to do for this single deadline expiration batch. Probably just one of the reasons why it's not considered ready for release.
This continues until React exhausts the work queue, and then it's up to user interactions or other application code to cause updates.
Any sync
batch ignores the scheduling. Any partial, non-committed work will be thrown out and the
loop will process and commit all sync
updates while ignoring everything else, even expired
non-sync
work. If this interrupted a partial update, it will then start over on top of the new
tree when the loop resumes as was originally scheduled.
React will protect you against infinite recursion of sync
updates in the commit phase by counting
how many times you caused a cascading update. Currently this limit is 50.
However, there is currently nothing to protect you against infinite async updates, other than the
max limit of a single queued interactive
update.
Yielding is intended to let the browser at least render a frame before continuing, but if we are already rendering an expired task, this will continuously synchronously drain the queue as long as there are expired tasks, even the freshly inserted already-expired continuation callback.
useEffect
don't really run inside a batch, but they just forbid the scheduler from updating the
currentSchedulerTime
which is used for deriving expirations. This means that all updates inside
it will share the same deadline, as the scheduler time has not advanced, and any deadlines will be
calculated as if the useEffect
s had been synchronously invoked on the previous commit.
Commenting here for posterity, even though I've told you this several times already:
This is a fantastic writeup, and you should totally turn it into a blog post.
And also start blogging publicly.
And then turn it into a blog post.