React Deep Dive
Chapter 1: The Problems with Pre-React 16
I remember when I first looked at my job's projects and saw the UNSAFE_ prefix on componentWillMount, componentWillReceiveProps, and componentWillUpdate. I had no idea what they were, so I hovered over them and read the comments above them. Unfortunately, I still couldn't understand them at all. This gap in understanding became the driving force behind my deep dive into React's architecture.
After working with React for several years and studying its internals, I've come to understand the why, what, and how behind React's design. By writing this blog, I aim to solidify my knowledge while hopefully helping others navigate React's core concepts more clearly.
What does JSX convert to?
1// JSX Syntax
2function App() {
3 return (
4 <div className="app">
5 <h1>Hello React!</h1>
6 <p>This is a paragraph</p>
7 </div>
8 );
9}
1// Compiled JavaScript
2function App() {
3 return React.createElement(
4 'div',
5 { className: 'app' },
6 [
7 React.createElement('h1', null, 'Hello React!'),
8 React.createElement('p', null, 'This is a paragraph')
9 ]
10 );
11}
JSX is transformed to JavaScript by build tools like Babel. The transformation replaces the code inside the tags with a call to createElement, passing the tag name, the props, and the children as parameters.
Starting from React 17, Babel uses a new JSX transform that uses _jsx instead of React.createElement. Let's look at a direct comparison using our example:
1// React.createElement (Pre-React 17)
2function App() {
3 return React.createElement(
4 'div',
5 { className: 'app' },
6 [
7 React.createElement('h1', null, 'Hello React!'),
8 React.createElement('p', null, 'This is a paragraph')
9 ]
10 );
11}
12
13// _jsx (React 17+)
14function App() {
15 return _jsx("div", {
16 className: "app",
17 children: [
18 _jsx("h1", {
19 children: "Hello React!"
20 }),
21 _jsx("p", {
22 children: "This is a paragraph"
23 })
24 ]
25 });
26}
The new transform has several advantages:
- It's more performant as it doesn't need to create intermediate arrays for children
- It automatically imports the necessary functions, so you don't need to import React just to use JSX
- It produces cleaner and more readable code
Both transforms ultimately create the same element object structure that we will discuss later. The main difference is in how they get there and their runtime performance. For now we can just stick to React.createElement.
What does React.createElement return?
The React.createElement function returns a plain JavaScript object that represents a React element. This object has a specific structure that React uses to build the DOM. Here's what the element object looks like for our example:
1{
2 type: 'div',
3 props: {
4 className: 'app',
5 children: [
6 {
7 type: 'h1',
8 props: {
9 children: [
10 {
11 type: 'TEXT_ELEMENT',
12 props: {
13 nodeValue: 'Hello React!',
14 children: []
15 }
16 }
17 ]
18 }
19 },
20 {
21 type: 'p',
22 props: {
23 children: [
24 {
25 type: 'TEXT_ELEMENT',
26 props: {
27 nodeValue: 'This is a paragraph',
28 children: []
29 }
30 }
31 ]
32 }
33 }
34 ]
35 }
36}
When dealing with primitive values like strings or numbers, React wraps them in a special element type called TEXT_ELEMENT. The TEXT_ELEMENT is a special type that React uses internally to handle text nodes. In the render function, when it encounters a TEXT_ELEMENT, it creates a text node using document.createTextNode() instead of a regular DOM element.
This element object is what gets passed to the render function. It's a lightweight description of what should appear on the screen, containing:
- type: The type of the element (either a string for HTML elements or a function/class for components)
- props: An object containing all the properties, including children which can be a string, number, or an array of more element objects
From JavaScript to DOM
Once we have our JavaScript element, we need to render it to the DOM. This typically happens with:
1const element = <App />;
2const container = document.getElementById("root");
3ReactDOM.render(element, container);
The render function is responsible for creating the actual DOM nodes and appending them to the container. Here's a simplified version of how it works:
1function render(element, container) {
2 const dom =
3 element.type == "TEXT_ELEMENT"
4 ? document.createTextNode("")
5 : document.createElement(element.type)
6
7 const isProperty = key => key !== "children"
8 Object.keys(element.props)
9 .filter(isProperty)
10 .forEach(name => {
11 dom[name] = element.props[name]
12 })
13
14 element.props.children.forEach(child =>
15 render(child, dom)
16 )
17
18 container.appendChild(dom)
19}
The Problem with Recursive Rendering
This implementation has a critical flaw: it uses recursive calls to render the entire element tree. The render function will keep calling itself for each child element until the entire tree is built. This means:
- The main thread is blocked until the entire tree is rendered
- If the tree is large, it can cause the UI to become unresponsive
- There's no way to pause or interrupt the rendering process
- High-priority updates (like user input) have to wait for the entire tree to be rendered
This was a fundamental limitation in React versions before 16. The recursive nature of the render process made it impossible to implement features like:
- Concurrent rendering
- Time-slicing
- Priority-based updates
In the next chapter, we'll explore how React 16's Fiber architecture solved these problems by introducing an interruptible rendering process.