Building Your Own React Clone in Five Easy Steps

React's ability to efficiently render declarative user interfaces can seem almost magical. Most developers probably have a rough idea of how it identifies changes and applies them using a "virtual DOM". But how many truly understand the details of what is happening behind the scenes?

Fascinated by this question, I decided to build my own rather naive React in order to get more familiar with the details of the implementation. Naturally my implementation doesn't offer the full power of React, but it is more than adequate to illustrate some of its key principles. I should mention in particular that I decided to support only stateless components (those don't have their own instance).

Getting to Know the Virtual DOM

Once we decide not to worry about managing internal state, the basic idea of our React is very straightforward. During each render cycle we first create a Virtual DOM (vDOM), which is nothing more than a "bushy" tree that mirrors the structure of the real DOM. Each node has three basic attributes:

  1. Type - a string identifying the DOM element such as div or span
  2. Props - in our case an object with properties corresponding to HTML attributes and event handlers
  3. Children - an array containing further nodes
{
  type: 'ul',
  props: { className: 'ul-wrapper' },
  children: [
    {
      type: 'li',
      props: { className: 'active' },
      children: ['First Item']
    },
    {
      type: 'li',
      props: { onClick: () => console.log('Hello, I have just clicked the second item') },
      children: ['Second Item']
    }
  ]
}

It is no coincidence that the vDOM representation looks very similar to the actual DOM. React identifies the differences between the two DOMs, uses these to generate a list of patches and then applies them to the real DOM. The vDOM has to use a similar representation so that the differences can be found efficiently.

Step One: The Virtual DOM Representation

Since version 0.14.0, React supports stateless components in the form of pure functions. These components are used like this:

const WelcomeComponent = ({ name }) =>
  <div>Welcome {name}!</div>;

const RootComponent = ({ user }) => {
  if (user) {
    return <WelcomeComponent name={user.name} />;
  } else {
    return <div>Please, Log in</div>;
  }
}

Since in this case a component is a function, we get some cool functional characteristics, such as dead easy composition, for free:

const welcome = name => `Welcome ${name}!`;

const root = user => {
  if (user) {
    // In this context composition just means calling
    // a function inside another function
    return welcome(user.name);
  } else {
    return 'Please, Log in';
  }
}

Now imagine that our stateless component is going to return a vDOM, i.e. a tree structure containing information about how the actual DOM should look.

const WelcomeComponent = ({ name }) => ({
  type: 'div',
  props: {},
  children: [`Welcome ${name}`]
});

This syntax is fairly verbose and error-prone, so let's make a function that returns a vDOM node. We'll call this function h since it is similar to Hyperscript.

const h = (type, props = {}, children = []) => ({
  type,
  props,
  children
});

const WelcomeComponent = ({ name }) => h('div', {}, [`Welcome ${name}`]);

const RootComponent = ({ user }) => {
  if (user) {
    // The vDOM node's type can also be another
    // component. React calls it automatically when creating
    // the vDOM tree
    return h(WelcomeComponent, { name: user.name });
  } else {
    return h('div', {}, [`Please, Log in`]);
  }
}

Seeing as an application that only uses one component isn't of much use, we need a way to combine multiple components. We've already mentioned simple composition, and you can see this in action in the above example. React allows the node type to be either a string or another component (i.e. function). Developers can easily create declarative UIs of arbitrary complexity using the magic h function. React itself gives us some syntactic sugar, in the form of JSX, to further ease this task, but we can do without this for our simple example.

We've already mentioned two important points:

  • Every rendering cycles starts by creating a vDOM representation.
  • The vDOM representation mirrors the actual DOM.

If we just called the RootComponent function and left it at that, we would violate the second point, since we would get an object whose type is a component and not a string:

{
  type: WelcomeComponent, // This is a function, not a string
  props: { user: { name: 'Tomas Weiss' }},
  children: []
}

However, the vDOM should correspond to the actual DOM:

{
  type: 'div',
  props: {},
  children: ['Welcome Tomas Weiss!']
}

The component tree must therefore be processed recursively, with nodes whose type is a component being called with the appropriate props until the whole structure has been transformed into a tree containing only DOM elements.

// To simplify the correlation of nodes in the vDOM and DOM,
// we generate a unique ID for each DOM node. These are just
// dot-delimited paths containing the index of each child in
// the list of children maintained by its parent.
// This allows us to use simple string manipulation to implement
// advanced features like a synthetic event system
export const createVDOM = (element, id = '.') => {
  // This function must be called recursively for all descendants
  // in order to transform the entire tree
  const newElement = {
    ...element,
    id,
    children: element.children.map((child, index) => createVDOM(child, `${id}${index}.`))
  };

  // Is this a component?
  if (typeof element.type === 'function') {
    // Call the component and pass in the props.
    // Returns the generated subtree
    const subtree = newElement.type(element.props);

    // Call ourself recursively in order to assign the right ID
    // to the nodes and process any subcomponents in the subtree
    return createVDOM(subtree, id);
  } else {
    // If we come across an element that is not a function,
    // all we have to do is return it
    return newElement;
  }
};

Step Two: Optimizing the Virtual DOM

Pure stateless components in React are referentially transparent by nature. They can therefore by replaced by the value they return. We can use this fact to our advantage by memoizing the component itself, as in this example:

const memoize = component => {
  
  // We need to record the previous props and return value of the component.
  // On the first call these are empty
  let lastProps = null;
  let lastResult = null;

  // We return a new component that uses the cached values for
  // optimization purposes
  return props => {

    // If the props have changed since the last function call,
    // we invoke the component with the new props and store the result
    if (!shallowEqual(props, lastProps)) {
      lastResult = component(props);
      lastProps = props;

      // We also record whether the result came from the cache. We will
      // use this information later when we create the vDOM
      lastResult.memoized = true;
    } else {
      lastResult.memoized = false;
    }

    // Finally we return the cached value. This is referential transparency
    // at work (i.e. we can replace the component invocation with its value)
    return lastResult;
  }
}

Now we can safely assume that all of our components are memoized and that the tree contains information about whether each value was cached or not. We can use this fact to great effect when creating the vDOM. We simply adjust the createVDOM function to stop recursing when it reaches a cached subtree, since we know in this case that the subtree is identical to the one returned by the previous call.

export const createVDOM = (element, id = '.') => {
  const newElement = {
    ...element,
    id,
    children: element.children.map((child, index) => createVDOM(child, `${id}${index}.`))
  };

  if (typeof element.type === 'function') {
    const subtree = newElement.type(element.props);

    // If we hit a cached subtree, we don't need to keep recursing
    if (subtree.memoized) {
      return subtree;
    } else {
      return createVDOM(subtree, id);
    }
  } else {
    return newElement;
  }
};

Step Three: Identifying DOM Changes

We already know how to use components to create a vDOM. Now let's create two different vDOM trees to demonstrate the tree diffing process:


// Component which can display text in either a div or a span
const RootComponent = ({ showDiv }) => {
  if (showDiv) {
    return h('div', {}, ['Hello World']);
  } else {
    return h('span', {}, ['Hello World']);
  }
}

// We create one tree that uses div
const leftVDOM = createVDOM(h(RootComponent, { showDiv: true }));

// ...and a second that uses span
const rightVDOM = createVDOM(h(RootComponent, { showDiv: false }));

const diff = (left, right, patches) => {
  // TODO: function that compares two trees and creates a
  // list of patches that transform the left tree into the right tree
}

const patches = [];
diff(leftVDOM, rightVDOM, patches);

Finding all the patches needed to transform the left tree into the right tree is a non-trivial problem with complexity O(n3). Luckily React uses some basic assumptions to greatly simplify this process. The fundamental assumption is that different components will always generate different DOM subtrees. As a result, we can short-circuit the tree comparison if we encounter a subtree whose root component has a different type from the corresponding node in the other tree. In this case we recreate the entire subtree from scratch, rather than performing the computationally intensive task of finding the exact differences.

In our sample implementation we will only deal with adding, removing and replacing nodes. In the real React, of course, we would also have to take into account prop changes.

const diff = (
  left,
  right,
  patches,
  parent = null
) => {
  // If the left side (i.e. current vDOM) node does not exist, we have to create it.
  // In this case we don't have to keep recursing since the creation process
  // itself is recursive
  if (!left) {
    patches.push({
      parent, // We pass in the parent of the left side node
              // so we know whether to attach the newly create descendants
      type: PatchTypes.PATCH_CREATE_NODE,
      node: right // And of course we pass in the newly created node
    });
  } else if (!right) {
    // If the right side (i.e. the new vDOM) node doesn't exist,
    // we need to remove the node from the vDOM
    patches.push({
      type: PatchTypes.PATCH_REMOVE_NODE,
      node: left // We just need to pass in the node to remove
    });
  } else if (left.type !== right.type) {
    // Here the type is changing and so we assume that the subtree
    // has changed and halt the recursion, greatly speeding up
    // the algorithm
    patches.push({
      type: PatchTypes.PATCH_REPLACE_NODE,
      replacingNode: left,
      node: right
    });
  } else if (right.memoized) {
    // Here we have another excellent and very effective optimization.
    // If we know that we are dealing with a memoized node, we
    // don't need to bother traversing the rest of the subtree since we
    // can be sure that its vDOM has not changed
    return;
  } else {
    // Now we iterate over all descendants of the left of right side
    // and call ourself recursively
    const children = left.children.length >= right.children.length ?
      left.children :
      right.children;

    children.forEach((child, index) => diff(
      left.children[index],
      right.children[index],
      patches,
      left
    ));
  }
};

Step Four: Applying Changes to the DOM

We already know how to create a vDOM and diff two vDOM trees. There is just one more piece to the puzzle: applying the changes to the actual DOM. We have prepared the ground since we have identified each vDOM node with a unique ID. We need to implement a function that applies patches in the DOM (let's call it applyPatch). (This code would be part of the react-dom package rather than react itself, since it is DOM-specific.) This is the only code we would need to change if we wanted to do server-side rendering or paint, for example, on an OpenGL context.

const ID_KEY = 'data-react-id';

// The correlation between vDOM and DOM nodes is simple.
// We just use the ID which we have recorded in the vDOM and place it in an
// HTML attribute of the actual DOM node
const correlateVDOMNode = (vdomNode, domRoot) => {
  if (vdomNode === null) {
    return domRoot;
  } else {
    return document.querySelector(`[${ID_KEY}="${vdomNode.id}"]`);
  }
}

// Creating the DOM node based on a vDOM node is a recursive operation,
// as we have already mentioned in the chapter about finding changes
// in the individual subtrees
const createNodeRecursive = (vdomNode, domNode) => {
  // Create a DOM element of the appropriate type
  const domElement = document.createElement(vdomNode.type);
  // Set the ID attribute so we can later correlate the vDOM and DOM
  domElement.setAttribute(ID_KEY, vdomNode.id);
  // And finally add the node to the DOM
  domNode.appendChild(domElement);

  // Recurse over the vDOM node's children
  vdomNode.children.forEach(child =>
    createNodeRecursive(child, domElement));
};

const applyPatch = (patch, domRoot) => {
  switch (patch.type) {
    case PatchTypes.PATCH_CREATE_NODE: {
      // First find the DOM node which corresponds to the vDOM node's parent
      const domNode = correlateVDOMNode(patch.parent, domRoot);

      // Recursively create the DOM node
      createNodeRecursive(patch.node, domNode);
    }
      break;

    case PatchTypes.PATCH_REMOVE_NODE: {
      // Find the DOM node that corresponds to the vDOM node that we
      // want to remove
      const domNode = correlateVDOMNode(patch.node, domRoot);

      // After that we just remove the node from its parent
      domNode.parentNode.removeChild(domNode);
    }
      break;

    case PatchTypes.PATCH_REPLACE_NODE: {
      // Find the DOM node that we want to replace
      const domNode = correlateVDOMNode(patch.replacingNode, domRoot);

      // Remove the node from its parent
      const parentDomNode = domNode.parentNode;
      parentDomNode.removeChild(domNode);

      // Recursively create the new node
      createNodeRecursive(patch.node, parentDomNode);
    }
      break;

    default:
      throw new Error(`Missing implementation for patch ${patch.type}`);
  }
};

Step Five: Render Function

Now we have everything we need to create the function that displays the DOM, applying any incremental changes that have occurred in the vDOM in the meantime. The idea is simple: the function remembers the previous DOM state and finds any changes with the newly create vDOM. These changes are then applied to the actual DOM.

export const createRender = domElement => {

  let lastVDOM = null;
  let patches = null;

  return element => {
    // First we create a new vDOM
    const vdom = createVDOM(element);

    // Next we get all the changes between the previous vDOM and the new one
    patches = [];
    diff(lastVDOM, vdom, patches);

    // Each patch is applied to the DOM
    patches.forEach(patch => applyPatch(patch, domElement));

    // Finally we record the new vDOM so we can use it for comparison purposes
    // during the next render cycle
    lastVDOM = vdom;
  };
};

// Simple demonstration that displays a modified DOM after a specified interval
const render = createRender(document.getElementById('app'));

let loggedIn = false;
setInterval(() => {
  loggedIn = !loggedIn;

  render(h(RootComponent, {
    user: loggedIn ? { name: 'Tomas Weiss' } : null
  }));
}, 1000);

As you can see, React is based on simple assumptions and functional programming principles. A naive implementation is not that complicated, although React naturally provides a lot more than just rendering.

If the idea of a simplified React is of interest to you, we strongly recommend that you follow the virtual-dom project, where these concepts are implemented in production quality. In a future article we will illustrate how React implements intelligent event delegation and look at the real-world performance implications.

This article would not have been possible without the excellent article by Christopher Chedeau @vjeux which inspired me to delve into the details of React and write my own simplified version.

Tomas Weiss

Tomas Weiss

Full-stack JavaScript developer at Salsita