[ANALYSIS] Render scheduling in React
Within scopes known to React - such as a useEffect()
hook body or an event handler function - all synchronous function calls that
manipulate state (using the useState()
hook) will be batched up automatically, thus leading to only a single scheduled re-render instead
of potentially multiple ones. [1,
2,
3]
For instance:
// State
const [value, setValue] = useState('');
const [otherValue, setOtherValue] = useState('');
const [thisValue, setThisValue] = useState('');
const [thatValue, setThatValue] = useState('');
// Side effects
useEffect(() => {
setOtherValue(`other ${value}.`);
setThisValue(`this ${value}.`);
setThatValue(`that ${value}.`);
}, [value]);
Let’s assume we update the value, e.g. within an event handler. For instance:
setValue('cool value');
Now, the following re-renderings happen:
Render | Description |
---|---|
1 | Calling setValue(‘cool value’) will lead to the value state variable being updated by theuseState() hook. Because updating state via the useState() hook will always trigger a re-render - at leastif the value has actually changed, which in our case it did - React triggers a re-render. |
2 | Our useEffect() hook lists the value state variable as a dependency. Thus, changing the value state variable value will always lead to the useEffect() hook being executed. Within our useEffect() hook,we update three other state variables (again, based on useState() ). Now: React has enough context for optimization: It knows whatuseEffect() is and does, and it has full control over / executes useEffect() . Thus, React intelligentlybatches up all three state changes instead of executing them directly, thus scheduling a single re-render instead of three separate ones. |
useEffect()
s / event handlersNow, things are different when asynchronous operations (e.g. promises, timeouts, RxJS, …) come into play.
For instance:
// State
const [value, setValue] = useState('');
const [otherValue, setOtherValue] = useState('');
const [thisValue, setThisValue] = useState('');
const [thatValue, setThatValue] = useState('');
// Side effects
useEffect(() => {
Promise.resolve().then(() => {
setOtherValue(`other ${value}.`);
setThisValue(`this ${value}.`);
setThatValue(`that ${value}.`);
});
}, [value]);
Let’s assume we update the value, e.g. within an event handler. For instance:
setValue('cool value');
Now, the following re-renderings happen:
Render | Description |
---|---|
1 | Calling setValue(‘cool value’) will lead to the value state variable being updated by theuseState() hook. Because updating state via the useState() hook will always trigger a re-render - at leastif the value has actually changed, which in our case it did - React triggers a re-render. |
2, 3, 4 | Our useEffect() hook lists the value state variable as a dependency. Thus, changing the value state variable value will always lead to the useEffect() hook being executed. Within our useEffect() hook,we update three other state variables (again, based on useState() ) once our promise resolves. Now: Due to our sideeffects being executed asynchronously (once the promise resolves), React does no longer have enough context for optimizations. Thus, in order to ensure that nothing breaks, React has no other choice than executing all state changes as per usual, leading to three separate re-renders. |
useEffect()
sAlso: Automatic batching / scheduling only works within each useEffect()
body, not across multiple useEffect()
s. If you have side
effects leading to new state that then triggers side effects - basically a chain of useEffects()
- React is not able to optimize this as
it is too unpredicable.
For instance:
// State
const [value, setValue] = useState('');
const [otherValue, setOtherValue] = useState('');
const [thisValue, setThisValue] = useState('');
// Side effects
useEffect(() => {
setOtherValue(`other ${value}.`);
}, [value]);
useEffect(() => {
setThisValue(`this ${value}.`);
}, [otherValue]);
Let’s assume we update the value, e.g. within an event handler. For instance:
setValue('cool value');
Now, the following re-renderings happen:
Render | Description |
---|---|
1 | Calling setValue(‘cool value’) will lead to the value state variable being updated by theuseState() hook. Because updating state via the useState() hook will always trigger a re-render - at leastif the value has actually changed, which in our case it did - React triggers a re-render. |
2 | Our first useEffect() hook lists the value state variable as a dependency. Thus, changing thevalue state variable value will always lead to the useEffect() hook being executed. Within ouruseEffect() hook, we update the otherValue state variables (again, based on useState() ). Thiswill lead to a re-render. |
3 | Our second useEffect() hook has the otherValue state variable as its dependency. Thus, the firstuseEffect() changing the otherValue state variable value will always lead to the seconduseEffect() hook being executed. Within our second useEffect() hook, we update the other two statevariables (again, based on useState() ). This will, again, lead to a re-render. |
When managing and distributing state outside of scopes known to React, e.g. when using RxJS (instead of
a React Context), React has no way of knowing how to optimize here. Even though the original data source (here our Observable) only emits
data once - at the same time, to all components subscribed to it - React will re-render every single component separately.
For instance:
const [value, setValue] = useState('');
const dataStream = useMyObservable();
useEffect(() => {
// Update value when it changes
const subscription = dataStream.subscribe((newValue) => {
setValue(newValue);
});
// Cleanup
return () => {
subscription.unsubscribe();
};
});
React itself (like many frontend frameworks) hides most of its implementation details and low-level APIs from us, so that we can concentrate
on building applications rather than deep diving into React internals. This also applies to the render pipeline and its scheduling
mechanisms.
unstable_batchedUpdates
APILuckily for us, though, React actually does expose one API that enables us to group / batch renderings: unstable_batchedUpdates
.
However, this API, like a few others React exposes, is prefixed with unstable
- meaning it this AP not part of the public
React API and thus might change or even break with any future release. But, the unstable_batchedUpdates
API is the most “stable unstable”
React APIs out there (see this tweet), and many popular projects rely upon it
(e.g. React Redux). So it’s pretty safe to use.
So, it’s pretty safe to use? For now, yes. Should it get removed at some point (e.g. React 17) we just need to remove theunstable_batchedUpdates
optimzations and - while performance might worsen - our code continues to work just fine. With the upcoming
Concurrent Mode, chances are good that the unstable_batchedUpdates
API might
actually become useless anyways as React will be intelligent enough to do most of the optimizations on its own. Until then,unstable_batchedUpdates
is the way to go.
In the rather simple use cases, we can use unstable_batchedUpdates
right away. A common scenario is running asynchronous code in auseEffect()
hook, and in that situation we can wrap all state change function calls in a single unstable_batchedUpdates
. This scheduling
is synchronous, as React will render changes right away instead of sometimes later in the current task.
For instance:
useEffect(() => {
Promise.resolve().then(() => {
+ unstable_batchedUpdates(() => {
setOtherValue(`other ${value}.`);
setThisValue(`this ${value}.`);
setThatValue(`that ${value}.`);
+ });
});
}, [value]);
Sometimes, for example if all state lives in the same component, it’s just easier to combine multiple useState()
s into a singleuseState()
, e.g. by combining state into an object.
For instance:
useEffect(() => {
Promise.resolve().then(() => {
+ unstable_batchedUpdates(() => {
- setOtherValue(`other ${value}.`);
- setThisValue(`this ${value}.`);
- setThatValue(`that ${value}.`);
+ setAdditionalValues({
+ otherValue: `other ${value}.`,
+ thisValue: `this ${value}.`,
+ thatValue: `that ${value}.`
+ });
+ });
});
}, [value]);
In more complex situations - e.g. when we want to schedule renderings across multiple components, perhaps even across multiple state
changes - we need to be a bit more creative. A custom scheduling solution could exist globally, allowing every component to schedule state
changes, and then either we (synchronously) or the browser (asynchronously) will run the state changes wrapped in unstable_batchedUpdates
.
The performance analysis below explores those custom scheduling solutions, and if / how they affect performance.
Of course, we want to get meaningful and consistent results when performance analysis each use case implementation. The following has been
done to ensure this:
node_modules
folders, but it keeps things clean. In particular:node_modules
folder. This way, we can easily run ourWhile all this certainly helps getting solid test results, there will always be things out of our control, such as:
All the performance profiling results documented below ran on the following system:
Area | Details |
---|---|
CPU | Intel Core i7 8700K 6x 3.70Ghz |
RAM | 32GB DDR4-3200 DIMM CL16 |
GPU | NVIDIA GeForce GTX 1070 8GB |
Storage | System: 512GB NVMe M.2 SSD, Project: 2TB 7.200rpm HDD |
Operating System | Windows 10 Pro, Version 1909, Build 18363.778 |
Within each test case implementation, the start-analysis.bin.ts
script is responsible for executing the performance analysis and writing
the results onto the disk.
In particular, it follows these steps:
Step | Description |
---|---|
1 | Start the server that serves the frontend application build locally |
2 | Start the browser, and navigate to the URL serving the frontend application |
3 | Start the browser performance profiler recording |
- | Wait for the test to finish |
4 | Stop the browser performance profiler recording |
5 | Write results to disk |
6 | Close browser |
7 | Close server |
Internally, we use Puppeteer to control a browser, and use the native NodeJS server API to serve
the fronted to that browser.
To run a performance analysis on a use case, follow these steps:
npm run install
npm run build
npm run start:analysis
The script will create the following two files within the results
folder:
profiler-logs.json
contains the React profiler resultsnpm start
and select aprofiler-logs.json
file.tracing-profile.json
contains the browser performance tracing timelineWe are running the performance analysis with the following parameters:
The following table shows a short test summary. See further chapters for more details.
Test case | Render time | Comparison (render time) |
---|---|---|
No scheduling | ~9.69ms | 100% (baseline) |
Synchronous scheduling by manual flush | ~1.88ms | 19.40% |
Asynchronous scheduling using Microtasks | ~1.81ms | 18.68% |
Asynchronous scheduling using Macrotasks | ~1.84ms | 18.99% |
Asynchronous scheduling using based on render cycle | ~1.84ms | 18.99% |
Concurrent Mode (experimental!) | ~1.85ms | 19.09% |
It should be noted that actual numbers are not that important, mainly because they will never be 100% exact and realisistic, partially also
because the Chrome performance tracing profiler and the React profiler have a hard-to-measure impact on the performance. What’s way more
interesting here is how faster or slower something got in comparison.
This test case shows how performance takes a hit when state is managed and propagated to components outside of scopes known to React, here
by RxJS observables. React will re-render each component separately.
The test results of this scenario represent the baseline for any further performance improvements.
The following chart shows how each state change leads a render step (re-rendering all components) that consists of multiple updates
(re-rendering one component).
A single update (one component re-renders) is very quick (about 0.05ms).
Although each single update (one component re-renders) is very quick, running all those updates separetely - with React being unable to
optimize across those updates - takes time. On average, we are talking about 9.5ms to 10ms, which is way too much when considering a
common frame budget (16.66ms).
Wow, that is one busy tracing profile! We can see lots of fragmentation, the lower parts representing the function calls of React re-rendering components separately.
In this test case, we schedule state changes instead of executing them right away, and then flush them manually (batched up) once all state
changes are propagated to components. In particular, we flush all tasks after every observable subscriber has processed the new value.
Implementation pointers:
The following chart shows that now each render step (re-rendering all components) contains only one update that now re-renders all
components at once instead of separately.
A single update equals a single render. On average, all component re-render at 1.8ms to 1.9ms time, which breaks down to a theoratical
component re-render time of 0.01ms.
This tracing profile looks very clean, very few function calls compared to the not-scheduled test case.Everything is executed within one synchronous block of code, even scheduled renderings.
In this test case, we schedule state changes instead of executing them right away in the form of Microtasks, meaning that the browser will
automatically flush all tasks once the currently running synchronous code has been completed and previsouly scheduled Microtasks have been
executed.
In order to schedule a Microtask, we use thequeueMicrotask()
API:
queueMicrotask(callbackFn);
As a fallback / polyfill (e.g. for older browsers), we could also use
Promises to schedule a Microtask:
Promise.resolve().then(callbackFn);
Implementation pointers:
The following chart shows that now each render step (re-rendering all components) contains only one update that now re-renders all
components at once instead of separately.
A single update equals a single render. On average, all component re-render at 1.8ms to 1.9ms time, which breaks down to a theoratical
component re-render time of 0.01ms.
This tracing profile looks very clean, very few function calls compared to the not-scheduled test case. Everything is executed within the same Macrotask, the first synchronous block containing the state change and its propagation to the components, the second Microtask block being the execution of all scheduled renderings right after.
In this test case, we schedule state changes instead of executing them right away in the form of Macrotasks, meaning that the browser will
automatically flush all tasks once the currently running synchronous code has been completed and all scheduled Microtasks have been
executed. It’s important to note that the browser might render an update on screen before our scheduled code executes.
In order to schedule a Macrotask, we use thesetTimeout()
API with a timeout of zero:
setTimeout(callbackFn, 0);
Implementation pointers:
The following chart shows that now each render step (re-rendering all components) contains only one update that now re-renders all
components at once instead of separately.
A single update equals a single render. On average, all component re-render at 1.8ms to 1.9ms time, which breaks down to a theoratical
component re-render time of 0.01ms.
This tracing profile looks very clean, very few function calls compared to the not-scheduled test case. The first Macrotask contains the state change and its propagation to the components, the second Macrotask runs all scheduled renderings. We can also clearly see how the browser decided to render a frame in between, which might happen when using Macrotasks for scheduling purposes.
In this test case, we schedule state changes instead of executing them right away by deffering them to the render cycle, meaning that the
browser will automatically flush all tasks right before it renders on screen.
In order to schedule based on the render cycle, we use therequestAnimationFrame()
API:
requestAnimationFrame(callbackFn);
Implementation pointers:
The following chart shows that now each render step (re-rendering all components) contains only one update that now re-renders all
components at once instead of separately.
A single update equals a single render. On average, all component re-render at 1.8ms to 1.9ms time, which breaks down to a theoratical
component re-render time of 0.01ms.
This tracing profile looks very clean, very few function calls compared to the not-scheduled test case. The first Macrotask contains the state change and its propagation to the components, the second block runs all scheduled renderings right before the browser renders on screen.
Bonus time: Let’s test that fancy cool new React concurrent mode. Here, we don’t have
a manual scheduler in place. Instead, we use the new ReactDOM.createRoot()
function to instantiate our application, thus enabling
concurrent mode, and otherwhise develop our application like we are used to.
Note: React concurrent mode is still highly experimental, and requires the application to run properly in strict mode!
The following chart shows that now each render step (re-rendering all components) contains only one update that now re-renders all
components at once instead of separately.
A single update equals a single render. On average, all component re-render at 1.8ms to 1.9ms time, which breaks down to a theoratical
component re-render time of 0.01ms.
This tracing profile looks very clean, very few function calls compared to the not-scheduled test case. The first Macrotask contains the state change and its propagation to the components. It seems that the React Concurrent Mode decided to schedule re-renderings into a separate Macrotask, using setTimeout(callbackFn, 0)
- interesting!