DEV Community

Cover image for Wait, React isn't about virtual DOM?
spyke
spyke

Posted on • Edited on

Wait, React isn't about virtual DOM?

Short answer for those who don't want to read:

Functional programming is the true theme of React. Virtual DOM is just one of its consequences and isn't really a benefit. It is slower by definition, and there could be no DOM at all if you're using React for sound or 3D rendering. Choose React if it matches your mindset, not because it was faster a couple of years ago.


Let's start with the opposite of virtual DOM: the real DOM. We're going to use an uncomplicated Counter component, who's content HTML may look like this:

<div>
  Count: 123
</div>
<div>
  <button type="button">Increment</button>
  <button type="button">Decrement</button>
<div>
Enter fullscreen mode Exit fullscreen mode

Imaging how would you build it using plain JavaScript. Probably you'll go by one of these 2 ways: createElement or innerHTML.

Creating elements manually is time consuming. Just buttons section is almost screen height:

class Counter {
  /* rest of the code */

  renderButton(text, handleClick) {
    const button = document.createElement("button");

    button.setAttribute("type", "button");
    button.textContent = text;
    button.addEventListener("click", handleClick);

    return button;
  }

  renderButtons() {
    const buttons = document.createElement("div");

    buttons.append(
      renderButton("Increment", this.handleIncrement),
      renderButton("Decrement", this.handleDecrement),
    );

    return buttons;
  }
}
Enter fullscreen mode Exit fullscreen mode

We need a createElement call per every node, to append all required children, etc. But having an element reference allows easy attaching event listeners.

innerHTML may look less, but needs ids/classes to assign listeners:

class Counter {
  /* rest of the code */

  render() {
    this.container.innerHTML = `
      <div>
       Count: <span id="label">${this.count}</span>
      </div>
      <div>
       <button type="button" id="btn-inc">Increment</button>
       <button type="button" id="btn-dec">Decrement</button>
      <div>
    `;

    this.label = document.getElementById("label");
    this.btnIncrement = document.getElementById("btn-inc");
    this.btnDecrement = document.getElementById("btn-dec");

    this.btnIncrement.addEventListener("click", this.handleIncrement);
    this.btnDecrement.addEventListener("click", this.handleDecrement);
  }
}
Enter fullscreen mode Exit fullscreen mode

We use less lines on setting attributes, but more on searching for elements for future updates and adding excess classes.

Of course, no one wants to do such work manually. That's why we've got UI libraries like Angular, Vue, Svelte, and others. These 2 options of building a Counter are roughly what we get in a template-based library.

The innerHTML is somewhat the original AngularJS: our bundle contains the template string and the engine runs on the client by parsing this template, finding slots for data and expressions inside it, inserting it into the page, and attaching methods as listeners. Larger bundle size and additional load on the browser are downsides of this approach.

The createElement is like modern Svelte/Ivy, where the template is parsed/compiled build time into a set of document manipulation commands, so no string embedding or runtime is required. We get less bundle overhead and the code is optimized specifically for our component, but at a cost of loosing features on the client.

Looks not that complicated, right?

That's because we forgot the part with the template language: conditions and repeaters. All the good stuff anyone can't really use templates without. Imagine adding that to our Counter code: instead of a simple innerHTML we need to parse the string and "run" dynamic parts. What if condition changes later, how we're going to find out about that? Will we re-render only dynamic parts or the entire component? The codebase will be complicated and much larger.

But there's more. What if we need to use a custom Button component?

<div
  component="Button"
  label="Increment"
  onclick="this.handleIncrement"
></div>
Enter fullscreen mode Exit fullscreen mode

It's doable. Just create this div element and pass it as a container to a class registered as Button. But it must be registered in advance:

const Button = require("../components/button.js");

UI.registerComponent("Button", Button);
Enter fullscreen mode Exit fullscreen mode

Attributes should be parsed to distinguish between div's HTML attributes and arguments to the Button. Basically the div is now a sub-tree and should operate on its own.

But what if we want to use not just a Button, but one of several components conditionally?

<div
  components="this.isLoading ? 'Button' : 'Image'"
  label="Increment"
  onclick="this.handleIncrement"
></div>
Enter fullscreen mode Exit fullscreen mode

It isn't a simple mapping anymore, but an expression, which needs to be compiled appropriately with JS executed at right times and the component instances destroyed/created. And those attributes may be re-parsed every time, because label could be an argument for a Button, but not for an Image.

Think about the original AngularJS with all its scopes, hierarchies, transclusion, etc. Complexity goes nuts with dynamically nested templates. That's why ng-include was static and we couldn't just render any template based on business logic.

But there's more. What if we need to build a component on the fly? Is it even possible, if template parsing and code emitting happens at build time?

We could get a team of super-stars and try to build an engine or a compiler providing all those features, but the point is that almost every feature influences the rules by which you will write template and/or logic because of it's complexity. And you're still somewhat restricted by a template.


Now, let's abstract away and get into a functional data driven land.

Everything in the world could be represented as a result of a function call and its arguments:

function(args) ⟶ anything
Enter fullscreen mode Exit fullscreen mode

Inside a function you can do any kind of things including calling other functions (composition). We had functions (methods) before in the Counter class too, but with different insides.

Instead of only producing a result, methods alter existing state (in our case document elements with append or innerHTML), especially on counter updates. In functional world it is forbidden and passed arguments are immutable. Even if we pass a container div into a function, it can't add nodes here. Instead, we should rely only on the returned value. And in case of an update, to re-execute the function and to get next result out of it.

As we draw a UI, return values should describe it somehow. We could return an HTMLElement, but it has imperative mutable interface. Anyway, manually using document APIs is time-consuming as we know. Let's revisit HTML of our component:

<div>
  Count: 123
</div>
Enter fullscreen mode Exit fullscreen mode

It's not that different from a JavaScript object.

const html = { element: "div", children: [
  "Count: 123"
] }
Enter fullscreen mode Exit fullscreen mode

An object notation is more verbose for sure, as a general language should be to a DSL. But we could easily build such objects ourselves without mutating anything (and parsing a template). We could even reduce boilerplate by implementing a little helper:

function element(name, ...children) {
  return { element: name, children };
}

const ui = element("div",
  "Count: 123"
)
Enter fullscreen mode Exit fullscreen mode

Moreover, objects can reference functions, so we don't need a map of pre-registered components:

function CounterLabel(children) {
  return element("div",
    "Count is ",
    element("span", ...children)
  );
}

const ui = element(CounterLabel, 0);
Enter fullscreen mode Exit fullscreen mode

And the result would be:

const counterLabelResult = {
  element: "div",
  children: [
    "Count is ",
    { element: "span", children: [0] }
  ]
};

const ui = { element: CounterLabel, children: [0] };
Enter fullscreen mode Exit fullscreen mode

Now we need somebody to recursively go through this object tree (UI description) calling functions (our components) inside element properties.

One more thing. A real-world UI needs to react on events like button click. How would we know to re-execute the function? Let's just pass a callback for this, which could be used, for example, as a click handler:

function FancyButton(children, refresh) { ... }
Enter fullscreen mode Exit fullscreen mode

Assume that we've made such function that processes the object tree recursively, simultaneously passing the callback. We will call it getDescriber:

function getDescriber(component) {
  /*
   const describeUI = ...
   ...
  */
  return refresh => describeUI(component, refresh);
}

const describer = getDescriber(Counter);
Enter fullscreen mode Exit fullscreen mode

describer accepts a refresh callback and outputs a complete UI description as a nested object of strings, numbers, and arrays (basically, a JSON).

The only part missing is a function to read this description and emit DOM elements into the document. We will call it render, and assume that we have its implementation already done by somebody:

function render(describer, mountNode) { ... }

render(describer, document.getElementById("root"));
Enter fullscreen mode Exit fullscreen mode

Let's recap. We have 2 parts and just 3 functions:

  1. element(name, ...children) and getDescriber(component) [react]
  2. render(describer, mountNode) [react-dom]

Part #1 consists of element and getDescriber used together to make a description. Part #2 is only render, which is used exclusively when you need to get actual HTML elements. Both parts are independent. The only thing that connects them together is the structure of the description. render expects a nested object with element and children properties. That's all.

Part #1 could do whatever it wants: generate functions/closures of the fly and execute them, check conditions of any complexity... Instead of adding another complicated template language syntax you just use the whole power of JavaScript. As long as it outputs required objects, no downsides or limits of template engines exist.

You can call this object description a virtual DOM, but only if you're using that particular render function from above. We can make render that instead of calling document.createElement will... play sounds! We may interpret the description as we want. Is it DOM anymore?

As you might guess Part #1 is react and Part #2 is react-dom.


React isn't about virtual DOM. It's about abstracting away physical body of your structured data and helping you to update that structure over time. You work on the structure and data with React, some one else will materialize that structure later. Web pages do have a structure, so it's convenient for React to have a materializer for DOM. If Facebook was a music company, maybe React would have shipped with react-midi instead.

React is about functional approach, abstraction, flexibility, and unidirectional flow. Virtual DOM is a consequence of using it in a browser. Reconciliation and partial updates aren't fast. Manually crafted set of DOM manipulations is more effective by definition, and compilators can do this for templates. But React allows you to think different about UI, not as about strings and markup. React allows you to use functional composition for UI structure and a real language for UI logic. It's a mindset thing.

Top comments (0)