The article’s goal is to shed some light on the framework internals and historical insights standing behind the implementation decision made by the React Team at the time. I assume you are already aware of basic JavaScript / React and JSX concepts. So let’s kick off with some history first.
It’s 2013. The React framework is born (version 0.3.0 is rolled out to the public) and it’s immediately loved by the community. It stands out with its simplicity, one-way-data-binding, and - what’s obvious - declarative API powered by original, attractive to the community syntax extension, JSX.
The following months and years bring new releases; bugs are fixed, features and improvements are added. The community grows, the tooling ecosystem supporting its development flourishes. React Native is embraced by mobile native developers, which brings even more popularity to the framework. But there is one thing around the framework that does not change at all. Its internal logic (so-called Reconciliation Algorithm) is responsible for all of the “magic” - starting from when an application's state changes until direct DOM updates are performed.
Briefly, here’s how it works:
(1) Every state change (e.g., clicking “Buy product” from the example application) forces building a so-called Virtual DOM, representing the current state of the application. It's a structure of components.
(2) Then, the newly created Virtual DOM tree is compared with a previously generated Virtual DOM tree representing the application’s state already displayed to a user. Discrepancies between those Virtual DOM structures are simple information, what we should change, e.g.:
- update attribute X for A element,
- remove element B,
- or append element C as a child of element A.
(3) The information is utilized by separate modules - renderers (for browsers, it’s react-dom) which applies the minimal set of changes necessary to update UI (the DOM nodes for browsers).
And that’s how React works, briefly.
But how is the Virtual DOM built? If we had to write the function responsible for that on our own, it might look like:
The render
function drills down through the entire React Elements structure (Virtual DOM) and reconciles (or works on) every element.
Let’s stop for a moment here. What is a React Element? A React Element is a simple building block for our application. It’s what we return from our components, like <Loader />
in our example application. It’s worth mentioning that React Elements are not DOM Elements. Whenever I refer to an “element” below in the article, I mean React Element. If we talk about DOM elements, I will explicitly use the “DOM” to avoid misunderstanding.
Back to the reconciliation. Reconciling here means doing some element-related work. For component elements, it includes:
- Invoking the
render()
method (for class-based components) or calling functional component’s function reference with given props, - managing internal state,
- invoking Lifecycle Methods (e.g.,
getDerrivedState
), - marking changes that need to be applied later on,
- and many more.
By calling render(<App />)
(I recommend getting familiar with our example application specified above, we will use this structure later on), we re-create the tree structure from top to bottom, using render
function:
- Starting from
render(App)
, we work on theApp
component, - then we have some work to do with its child (
Content
), - then with its child’s children:
Loader
,Product
, - and so on, until we reach the last leaf of the structure of the elements.
It’s 2016. Everyone is celebrating the framework's 3rd birthday (version 15.0.0 is released), but its parents (React Core Team with its lead, Sebastian Markbåge - we will talk about him later in the article) are slightly worried about the future of the framework. But is there a reason to be concerned?
It appears that the React has some “innate heart disease”, which limits its organic growth. Let’s have a brief look:
What’s common among render()
and fib()
functions?
You’re right. It's a recursion. The heart of the React framework relies strongly on recursion. But is it a problem at all?
Web browsers are equipped with a single thread. We can do one operation at a time, so React operates in a limited environment. Although computing fib(4)
is not a challenge at all, computing fib(4000)
definitely is. Same for the Reconciliation Algorithm - building a Virtual DOM based on a thousand elements is a real challenge. Synchronous code execution blocks the main thread, so JavaScript’s event loop has to wait until the end of execution. During that time, none of the following activities can be performed:
- User input (e.g., handling user click event callback)
- Animations, layout calculations, repaints
- Handle incoming data (HTTP, WebSocket)
There is a brilliant talk about event loop here, so if you are not familiar with it or need a refresher, it’s definitely worth watching.
Let’s talk now about the JS Stack; how does it look for both the fib
and render
functions?
The JS Stack grows as we move deeper in the structure, so the process simply can’t be paused because there is no straightforward way to do that in a recursion. We reconcile all of the elements in one shot or none at all. What’s more, React's computation output is pushed onto the JS stack, so it's ditched immediately after the render
function returns. There is no way to reuse this work later on if it’s needed.
Imagine a case of a heavy application with a massive number of components. We are in the middle of the Reconciliation Algorithm, and a user clicks a button. Such action is critical from a UX standpoint and should be handled immediately. But what happens?
- Clicking dispatches a DOM event.
- The event callback lands in a queue and waits (until the JS Stack is empty) to be processed.
- But the JS stack is “overwhelmed” by heavy React-related work, so the event callback waits…, waits, and waits for its turn until the Reconciliation Algorithm is done.
There is an excellent Sierpinski triangle example application on Github. It is a more tangible showcase of the problem. Keep in mind that it’s 2016, so the application is built on top of React 15.x. Here is how the application looks like:
Each dot is a component displaying a number. Among state updates, there are a lot of other computations, including:
- animations (layout computations, painting),
- deliberate delays,
- a whole bunch of artificial and pointless state changes.
All of these simulate a heavy application. And here is the application. Pretty sluggish, huh? Here’s how the top of the JS Stack looks like (I recommend watching this short video).
A synchronous and time-consuming function (in the Sierpinski’s triangle example, for my equipment, each “Task” takes ~300ms) reconciles the entire application from the top to the bottom of the elements tree, no matter what.
The framework here is relentless. It overwhelms the main thread, which can’t perform any other types of work (animations, user’s input). It introduces a significant impact on the browser’s performance. It’s hard to build on the top of such architecture, isn’t it?
And this is a huge limitation for the React Team.
Of course, this is an example application built to show the problem. But we can quickly observe such pitfalls when the number of components grows in our real-world applications. Long and heavy lists are a perfect example here. It’s also why the helper libraries, like react-virtualized (or its lightweight version, react-window), emerged and gained noticeable popularity by supporting the framework. Those libraries are officially recommended on React docs pages.
It’s also worth noticing that React is clever enough, and implements caching, exposes the keys API, introduces some trade-offs to reinforce performance which is a real deal, but still - it’s not enough to move forward (If you are interested in in-depth details of the algorithm, visit official docs here).
In the next part, I will explain what approach the React Team took to address those limitations.
Top comments (2)
Thank you for the article, super interesting one! Looking forward to the next part :)
Nice! Thanks for this deep dive!