React Internals: Renderer
React Internals: Renderer

React Internals: Renderer

Published
July 1, 2023
Author

The two big phases of React

Reconciler

What is reconciliation?

Reconciliation is the algorithm React uses to diff one tree with another to determine which parts need to be changed.
Reconciliation is the algorithm behind what is popularly understood as the "virtual DOM." A high-level description goes something like this: when you render a React application, a tree of nodes that describes the app is generated and saved in memory.
We are encouraged to not use the term "virtual DOM" because it is a bit of a misnomer in host environments that are not concerned with the DOM
This tree is then flushed to the rendering environment — for example, in the case of a browser application, it's translated to a set of DOM operations

Why is reconciliation necessary?

In short,
Re-rendering the entire app on each change is prohibitively costly in terms of performance. React has optimizations which create the appearance of whole app re-rendering while maintaining great performance. The bulk of these optimizations are part of a process called reconciliation.

Renderer

What is a renderer?

React was originally created for the DOM but it was later adapted to also support native platforms with React Native. This introduced the concept of “renderers” to React internals.
The common renderers are:
  1. react-dom https://github.com/facebook/react/tree/main/packages/react-dom
  1. react-native-renderer https://github.com/facebook/react/tree/main/packages/react-native-renderer
  1. react-test-renderer https://github.com/facebook/react/tree/main/packages/react-test-renderer

Why is a renderer necessary?

Renderers manage how a React tree turns into the underlying platform calls.
  • What is meant by a host environment(underlying platform)?
    • e.g. Native vs Web
  • Why would a different host environment matter?
    • Different host environments have different host(built-in) components
    • Host components for web: div, span, img etc
    • Host components for native: View, Text, Image etc
  • What is the main purpose of a renderer
    • To act as a bridge between the reconciler and a host environment

Reconciliation versus rendering

  1. Renders and reconcilers are completely different libraries.
  1. React is designed so that reconciliation and rendering are separate phases.
    1. The reconciler does the work of computing which parts of a tree have changed
    2. The renderer then uses that information to actually update the rendered app.
    3. This separation means that React DOM and React Native can use their own renderers while sharing the same reconciler, provided by React core.

Diving into a renderer for the web

Understanding the web render process

Assuming a web application, the render process happens in 4 steps:
  1. A render is triggered
  1. Rendering a component(render phase)
  1. Committing to the DOM(commit phase)
  1. Browser repaints
People often say that updates happen in the reconciler in two phases: render and commit phase

Triggering a render

There are two reasons for a component to render:
  1. It’s the component’s initial render.
  1. The component’s (or one of its ancestors’) state has been updated.

Rendering a component

“Rendering” is React calling your components.
  • On initial render, React will call the root component.
  • For subsequent renders, React will call the function component whose state update triggered the render.
    • During a re-render, React will calculate which of their properties, if any, have changed since the previous render. It won’t do anything with that information until the next step, the commit phase.

Committing to the DOM

After rendering (calling) your components, React will modify the DOM.
  • For the initial render, React will use the appendChild() DOM API to put all the DOM nodes it has created on screen.
  • For re-renders, React will apply the minimal necessary operations (calculated while rendering!) to make the DOM match the latest rendering output.
React only changes the DOM nodes if there’s a difference between renders.

Browser repaints

After rendering is done and React updates the DOM, the browser will repaint the screen.

Understanding a renderer(by building one)

Full disclosure: the following content is heavily referenced from a talk by Sophie Alpert, React Core Team's Engineering Manager.
Reconciliation is the algorithm React uses to diff one tree with another to determine which parts need to be changed.
Renderers manage how a React tree turns into the underlying platform calls.
Abstractly speaking, the output of reconciliation is a minimal list of commands that needs to happen. This could be things like
  1. Update view X to be red
  1. Create a new view
  1. Add view X and a child of view Y
A renderer, which is the bridge between the reconciler and the host environment, is then likely responsible for implementing how the commands work
  • For a browser, this means writing implementation with DOM operations

View [app.js]

import React from 'react'; import logo from './logo.svg'; import './App.css'; let colors = ['red', 'green', 'blue']; let i = 0; function App() { let [showLogo, setShowLogo] = React.useState(true); let [color, setColor] = React.useState('red'); return ( <div className="App" > <header className="App-header"> {showLogo && <img src={logo} className="App-logo" alt="logo"/>} <button onClick={() =>setShowLogo(show => !show)}>Toggle logo visibility</button> <p bgColor={color} onClick={() => { i++; setColor(colors[i % 3]); }}> Edit <code>src/App.js</code> and save to reload. </p> <a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer" > Learn React </a> </header> </div> ); } export default App;

Entry point [index.js]

import React from 'react'; import ReactDOMMini from './ReactDOMMini'; import './index.css'; import App from './App'; ReactDOMMini.render(<App />, document.getElementById('root'));

Renderer [ReactDOMMini.js]

We'll call our renderer ReactDOMMini and start implementation from scratch
  1. ReactDOMMini
    1. Must implement a render method
    2. render method must somehow communicate with the reconciler
    3. We know that the renderer should write implementations with DOM APIs
    4. import ReactReconciler from 'react-reconciler'; let reconciler = ReactReconciler({...}); let ReactDOMMini = { render(whatToRender, div) { // the reconciler has its own notion of a container, so we have to call createContainer() // the other arguments are to disable concurrent mode or server side hydration let container = reconciler.createContainer(div, false, false); reconciler.updateContainer(whatToRender, container, null, null); }, }; export default ReactDOMMini;
  1. Reconciler
    1. Accepts configurations as props
    2. As part of the configurations, a renderer must provide its own implementation of a set of methods(interface)
    3. { supportsMutation: true, // Called whenever a non-text host component is created createInstance( type, props, rootContainerInstance, hostContext, internalInstanceHandle, ) {}, // Called whenever a text host component is created createTextInstance( text, rootContainerInstance, hostContext, internalInstanceHandle, ) {}, appendChildToContainer(container, child) {}, appendChild(parent, child) {}, appendInitialChild(parent, child) {}, removeChildFromContainer(container, child) {}, removeChild(parent, child) {}, insertInContainerBefore(container, child, before) {}, insertBefore(parent, child, before) {}, prepareUpdate( instance, type, oldProps, newProps, rootContainerInstance, currentHostContext, ) {}, commitUpdate( instance, updatePayload, type, oldProps, newProps, finishedWork, ) {}, finalizeInitialChildren() {}, getChildHostContext() {}, getPublicInstance() {}, getRootHostContext() {}, prepareForCommit() {}, resetAfterCommit() {}, shouldSetTextContent() { return false; }, }
  1. createInstance
    1. createInstance( type, props, rootContainerInstance, hostContext, internalInstanceHandle, ) { console.log('createInstance:', { type, props }); let el = document.createElement(type); // set DOM attributes from react props ['alt', 'className', 'href', 'rel', 'src', 'target'].forEach(k => { if (props[k]) el[k] = props[k]; }); // add event listeners if (props.onClick) { el.addEventListener('click', props.onClick); } // other self-defined props if (props.bgColor) { el.style.backgroundColor = props.bgColor; } return el; },
    2. From the logs, notice that we only deal with host components, i.e. <App /> is ignored
  1. createTextInstance
    1. createTextInstance( text, rootContainerInstance, hostContext, internalInstanceHandle, ) { return document.createTextNode(text); },
  1. appendChild and its variants
    1. // Called when React wants to add an element into its component hierarchy // container/parent: DOM element // child: DOM element or node returned from createInstance or createTextInstance appendChildToContainer(container, child) { container.appendChild(child); }, appendChild(parent, child) { parent.appendChild(child); }, appendInitialChild(parent, child) { parent.appendChild(child); },
    2. Generally called when React wants to add an element into its component hierarchy
    3. The three variants are called in specific situations that we don't really care about
  1. removeChild/insertBefore and their variants
    1. // used in removing an element removeChildFromContainer(container, child) { container.removeChild(child); }, removeChild(parent, child) { parent.removeChild(child); }, // used when adding a element in the middle of a list insertInContainerBefore(container, child, before) { container.insertBefore(child, before); }, insertBefore(parent, child, before) { parent.insertBefore(child, before); },
  1. Updates
    1. // Called when a component is updated(render phase) // returns an update payload prepareUpdate( instance, type, oldProps, newProps, rootContainerInstance, currentHostContext, ) { let payload; if (oldProps.bgColor !== newProps.bgColor) { payload = {newBgColor: newProps.bgColor}; } return payload; }, // Called when a component is updated(commit phase) commitUpdate( instance, updatePayload, type, oldProps, newProps, finishedWork, ) { if (updatePayload.newBgColor) { instance.style.backgroundColor = updatePayload.newBgColor; } },

Demo: making observations on state changes

  1. createInstance is called on initial render
  1. appendChildToContainer is necessary for .render()
  1. Toggling logo will call createInstance and removeChild
  1. Handling customProps
  1. Handling state updates

What is missing

  1. Handling all props, besides bgColor
  1. Handling uncontrolled components
  1. Handling refs
  1. Etc etc

Next steps

As of React v16, the internals of the reconciler were completely rewritten with the main change being the introduction of React Fiber
Fiber reimplements the reconciler. It is not principally concerned with rendering, though renderers will need to change to support (and take advantage of) the new architecture.
This supports more advanced(experimental) features such as suspense, concurrent mode and incremental rendering.
  1. Understanding React Fiber
  1. Understanding concurrent renderers(v18)

References

  1. react.dev
  1. legacy.reactjs.org
  1. https://github.com/acdlite/react-fiber-architecture
  1. https://sophiebits.com/2019/10/24/building-a-custom-react-renderer