How React Works Under the Hood

React is a JavaScript library for building user interfaces. Let's break down how it operates behind the scenes in a simple way.

When we open a web page, browser receives an HTML document from server, then constructs a logical, tree-like structure to show the page known as the Document Object Model (DOM)

User needs to interact with the structure, style, and content of web pages, so DOM update is needed, using the DOM API. While the DOM API itself is not inherently slow, frequent updates to the DOM are costly because the browser has to recalculate CSS, do layout, and repaint the screen, especially in complex UIs

React's solution is to use a virtual DOM. Instead of updating the DOM directly, React creates a virtual DOM from new state of UI and compares with previous virtual DOM, then updates only changed objects in real DOM, which makes the process faster.

React’s Internal Process

When you write JSX, you’re using either functional or class components in React. React internally invokes:

Both are ultimately transformed into React.createElement() calls, which return React elements (virtual DOM).

For example:

// JSX: return <Component anyProp1={26} anyProp2="example">Some text</Component> // Translated to: return React.createElement(Component, {anyProp1: 26, anyProp2: "example"}, "Some text") // React element object: {type: Component, props: {anyProp1: 26, anyProp2: "example"}, children: ["Some text"]}

So basically;

  1. You wrote the code
  2. Code is being compiled in a way that React.createElement() can be called with
  3. React.createElement() returns a Javascript object which represents virtual DOM

JavaScript Compilers

Compilers like Babel, Vite, and Esbuild convert JSX into React.createElement calls. This conversion allows React to work with plain JavaScript objects that represent the virtual DOM.

Lets try to illustrate the concept by writing very simple & dummy compiler like code. Please keep in mind; this no near to actual implementation, the aim is making you familiar with the concept.

const fs = require("fs"); // Read jsx file const regex = /["']/g; // Regex to remove quotes // Convert JSX to React.createElement calls function compileJSX(jsx) { let compiled = jsx; // Handle self-closing tags compiled = compiled.replace(/<(\w+)([^>]*)\/>/g, (match, tag, attributes) => { return `React.createElement('${tag}', {${parseAttributes(attributes)}}), `; }); // Handle opening tags compiled = compiled.replace(/<(\w+)([^>]*)>/g, (match, tag, attributes) => { return `React.createElement('${tag}', {${parseAttributes(attributes)}}, `; }); // Handle closing tags compiled = compiled.replace(/<\/\w+>/g, "), "); // Handle text content compiled = compiled.replace(/>([^<]+)</g, (match, text) => { return `, '${text.trim()}', `; }); // Remove new lines and extra spaces compiled = compiled.split("\n"); compiled.splice(0, 2); compiled.splice(-2); const paddingLength = compiled[0].match(/^\s+/)[0].length; compiled = compiled .filter((n) => n !== "") .map((n) => n.slice(paddingLength)) .join("\n"); return compiled; } function parseAttributes(attributes) { if (!attributes.trim()) return ""; // Convert attributes to object return attributes .trim() .split(/\s+/) .map((attr) => { const [key, value] = attr.split("="); return `${key}: ${value.replace(regex, "")}`; }) .join(", "); } // Read JSX from a file const jsxFileContent = fs.readFileSync("ExampleComponent.jsx", "utf8"); // Compile JSX to React.createElement calls const compiledCode = compileJSX(jsxFileContent); // Print the compiled code console.log(compiledCode);

The output for a component like:

const ExampleComponent = ({ title, children, imageUrl }) => { return ( <div> <h1>{title}</h1> <img src="https://example.com/example.jpg" alt="Example_visual_content" /> <ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> </ul> <button>Click me</button> </div> ); };

Would be:

React.createElement('div', {}, React.createElement('h1', {}, {title}) React.createElement('img', {src: 'https://example.com/example.jpg', alt: Example_visual_content}) React.createElement('ul', {}, React.createElement('li', {}, Item 1) React.createElement('li', {}, Item 2) React.createElement('li', {}, Item 3) ) React.createElement('button', {}, Click me) ) )

Once the output is executed, React will generate React Elements.

So congrats, we wrote our first dummy compiler-like code.

Reconciliation

So far in summary;

  1. Code written in JSX
  2. Code compiled in a way that React.createElement() can be called
  3. React.createElement() called and created a JS object which represents virtual DOM.

Now, its time for actual DOM comes into play.

As next step after generating Virtual DOM, React will diff the virtual DOM, and collects a list of all the changes that need to be applied to the real DOM. The diffing and calculation process is known as "reconciliation". We can divide it into 2 phases;

  1. Initial Rendering: The virtual DOM is directly transformed into the real DOM.
  2. Re-rendering: React compares the trees and finds the least number of operations to transform one tree into another using diffing algorithm. It tries to differentiate the trees to update only to the affected nodes in the real DOM.

For re-rendering due to performance measures, React needs to transform 1 tree into another with minimum operations.

Tree comparison algorithms generally have high complexity, often O(n^3);

React developers designed an algorith using Heuristic algorithm with complexity O(n) best case

To achieve this, there are several assumptions to optimize the performance for common cases;

** Above assumptions are true for most of the cases

Understanding these assumptions can help you write more performant React code.

For example lets say we want to render ExampleComponent with different modes (edit & create);

const ChildrenComponent = () => { useEffect(() => { console.log("component MOUNT"); return () => { console.log("component UNMOUNT"); }; }, []); return ( <img src="https://example.com/example.jpg" alt="Example_visual_content" /> ); }; const ExampleComponent = (mode) => { if (mode === "edit") { return ( <div className="container"> <div className="image"> <ChildrenComponent /> <DeleteUpdateControls /> </div> </div> ); } return ( <div className="container"> <ChildrenComponent /> </div> ); };

If mode prop changes for rendering ExampleComponent, the algorithm revise the component from top to bottom. First one is <div className="container"> and it remains but <div className="image"> and <ChildrenComponent /> makes change of types, so it means;

  1. previous img component is removed
  2. component UNMOUNT logged
  3. Tree is rebuild
  4. component MOUNT logged.

Lets now remove the <div className="image">

const ExampleComponent = () => { if (mode === "edit") { return ( <div className="container"> <ChildrenComponent /> <DeleteUpdateControls /> </div> ); } return ( <div className="container"> <ChildrenComponent /> </div> ); };

Now it will optimize a bit and our image component will not unmounted and mounted everytime, mode changes.

But what if the component was like;

const ExampleComponent = () => { if (mode === "edit") { return ( <div className="container"> <DeleteUpdateControls /> <ChildrenComponent /> </div> ); } return ( <div className="container"> <ChildrenComponent /> </div> ); };

React will again makes comparison top to bottom and see a change in types and again;

  1. Previous img component is removed
  2. component UNMOUNT logged
  3. Tree is rebuild
  4. component MOUNT logged.

If we change a code little;

const ExampleComponent = () => { return ( <div className="container"> {mode === "edit" && <DeleteUpdateControls />} <ChildrenComponent /> </div> ); };

Now in the tree, the place is reserved for boolean, although its not visible, and the position of image component will always same (unnecessary mount and unmount wont happen).

So as a result knowing the assumptions (in heuristic approach) will make you solve strange behaviours at first glance.


React's concurrent rendering feature (introduced with React 18)

Allows updates to be prioritized based on urgency, but it is not the case with synchronous rendering, which is used by default in earlier versions of React. After the virtual DOM creation and comparison with actual DOM and extracting which nodes should be updated before re-rendering, there is one more step called prioritization. This is another topic, I wont go in details but in short;

After extracting the nodes need to be updated, React makes prioritization according to the urgency of their getting to the user because we want the application not to lag. So React makes ordering of tasks (update of nodes) from high to low priority and updates the tree in that order, so change will be smooth.

Commit phase

So far in summary;

  1. Code written in JSX
  2. Code compiled in a way that React.createElement() can be called
  3. React.createElement() called and created a JS object which represents virtual DOM.
  4. With Heuristic approach we extracted the list of nodes in tree-like structure that needs to be updated
  5. Ordered the extracted list of nodes with respect to their priority.

Once the changes to the virtual DOM are identified, React enters the commit phase, where it updates the actual DOM. On initial rendering uses multiple DOM operations based on what elements are being rendered, which might include appendChild(), setAttribute(), removeChild(), etc.. For re-renders, the minimal necessary operations are applied. React library does not communicate with the real DOM directly, instead it uses Renderers like React DOM (for web platforms) and React Native (for mobile platforms).

React schedules rendering work asynchronously in the "render phase" and prioritizes tasks (introduced in React 18), but once it reaches the commit phase, the updates are applied synchronously, meaning it occurs in one go without interruption to ensure that the DOM never displays partial results for consistent UI. After manipulation of actual DOM the browser recalculates styles, performs layout and repaints the screen to reflect the updated UI. This process is handled internally by the browser and is independent of React.

Some phases (mentioned above) managed by React, some phases involves other packages (like ReactDOM). This is why we import both React and ReactDOM.

Conclusion

In this post, we covered the basic mechanics of React's virtual DOM, the reconciliation process, and how React efficiently updates the real DOM through the commit phase. By understanding React's underlying architecture, you can write better-optimized code and troubleshoot performance issues more effectively.

References

Wrapping up

Thanks for reading it, hope you like it. If you want to discuss about any information above, drop an email to sinantalhakosar[at]gmail.com. Cheers 🥳