DEV Community

loading...
Cover image for Build your own React.js - Part 3. MultiChild

Build your own React.js - Part 3. MultiChild

Rinat Rezyapov
I'm a front-end developer in a constant state of learning and exploring new things.
・10 min read

Table Of Content

A flowchart of what we will cover in this article

Introduction

This is the third part of Build your own React.js series. Click here if you didn't read the second part.

In this part, we are going to implement the logic for rendering multiple children. It's a very important feature because without it React.js applications would consist of only one element and one child. Although you could have such mini components in your app it's not practical.

Two cases of multichild

In the previous article, we rendered our first class component into DOM.

  // index.js

  class App extends Component {
    render() {
      return {
        type: "div",
        props: { children: `We are building ${this.props.title}` }
      };
    }
  }
Enter fullscreen mode Exit fullscreen mode

Our App class component has a children prop which is basically a string. JSX version of it would look like this:

  // index.js

  class App extends Component {
    render() {
      return (
        <div>
          {`We are building ${this.props.title}`}
        </div>
      )
    }
  }
Enter fullscreen mode Exit fullscreen mode

How to render more children? To do this, we need to be able to handle an array of children. Like this:

  // index.js

  class App extends Component {
    render() {
      return {
          type: "div",
          props: { 
            children: [
              {
                type: "h1",
                props: { 
                  children: `We are building ${this.props.title}` 
                }
              }, 
              {
                type: "h2",
                props: { children: `And it's cool!` }
              }
            ]
          }
      }
    }
  }

Enter fullscreen mode Exit fullscreen mode

or JSX version:

  // index.js

  class App extends Component {
    render() {
      return (
           <div>
             <h1>
               {`We are building ${this.props.title}`}
             </h1>
             <h2>
               {`And it's cool!`}
             </h2>
           </div>
      )
    }
  }

Enter fullscreen mode Exit fullscreen mode

And here I want to stop a little bit on the cases of multi-child. There are actually two that you probably use every time but not aware that they are handled by React.js a little bit differently.

The first case is rendering DOM elements by declaring them in the render method like this:

    <div>
      <h1></h1>
      <h2></h2>
    <div>
Enter fullscreen mode Exit fullscreen mode

Another one is rendering an array of DOM elements or strings like you usually do when you get an array of data from the server and want to show this data to the user:

   <div>
     {["Are we?", "Really?", ["Well...", "That is impressive"]]}
   <div>
Enter fullscreen mode Exit fullscreen mode

Doesn't matter if in reality we would map over it and wrap each string into p element, React.js will handle any case.

Note, that an array can consist of subarrays and these cases also should be handled.

Why I separate these cases? Because in the second one, when we render an array of subarrays there is no such term as children. You can't say that subarray ["Well...", "That is impressive"] is children of its parent array, it's still children of div but we need to somehow indicate that it's subarray when we render it.

Yes, it's confusing so let's build MultiChild class to clarify this.

Implementing MultiChild class

From the previous articles, we remember that instantiateComponent function handles elements with children. It checks if the type of the element object is string e.g. 'div' and if so passes it to HostComponent.construct which in turn uses DOMComponentWrapper to create DOM element:

// Component.js

function instantiateComponent(element) {
  let wrapperInstance;
  if (typeof element.type === "string") {
    wrapperInstance = HostComponent.construct(element);
  }
  ...
  return wrapperInstance;
}

// HostComponent.js

function construct(element) {
  return new DOMComponentWrapper(element);
}
Enter fullscreen mode Exit fullscreen mode

Since DOMComponentWrapper class creates the actual DOM element and it's children we need to handle multi children there. We will extend the existent _createInitialDOMChildren method of the DOMComponentWrapper class with the handler for props.children of array type:

// DOMComponentWrapper.js

//before
_createInitialDOMChildren(props) {
    if (typeof props.children === "string") {
      this._domNode.textContent = props.children;
    }
  }

//after
  _createInitialDOMChildren(props) {
    if (typeof props.children === 'string') {
      this._domNode.textContent = props.children;
    } else if (Array.isArray(props.children)) {
      let mountImages = this.mountChildren(props.children);
      DOM.appendChildren(this._domNode, mountImages);
    }
  }
Enter fullscreen mode Exit fullscreen mode

You probably spotted a new this.mountChildren method there that DOMComponentWrapper class doesn't have. This method is a method of MultiChild class that we are going to implement and extend DOMComponentWrapper from it.

  // MultiChild.js

  class MultiChild {

   const renderedChildren = ChildReconciler.instantiateChildren(children);
   this._renderedChildren = renderedChildren;

    return Object.keys(renderedChildren).map((childKey, i) => {
      let child = renderedChildren[childKey];
      child._mountIndex = i;
      return Reconciler.mountComponent(child);
    });
  }
Enter fullscreen mode Exit fullscreen mode
// DOMComponentWrapper.js

class DOMComponentWrapper extends MultiChild {
  constructor(element) {
    super();
    this._currentElement = element;
    this._domNode = null;
  }
...
Enter fullscreen mode Exit fullscreen mode

Notice we added super() into DOMComponentWrapper constructor. It's basically the way of calling MultiChild class (parent) constructor before using DOMComponentWrapper class (child) constructor. More about that is here.

Now let's stop at ChildReconciler.instantiateChildren. The author of the original implementation added the following comment:

Instantiate all of the actual child instances into a flat object. This handles all of the complicated logic around flattening subarrays.

By reading this you normally think that it takes an object of elements and it's children and children's children and transforms it into a flat object. And it's a valid thought. But turns out that this function is implemented for slightly different purposes.

Unfortunately, our App class component has quite simple elements structure in its render and we won't see both two cases of multi-child that I mentioned later. So let's change the children prop of h1 element into an array in our App class component:

// index.js

{
      type: "div",
      props: { 
        children: [
          {
            type: "h1",
            props: { 
              children: [
                `We are building ${this.props.title}`, 
                [
                  'Are we?', 
                  'Really?', 
                  [
                    'Well...', 
                    'That is impressive'
                  ]
                ]
              ]
            }
          }, 
         {
           type: "h2",
           props: { children: `And it's cool!` }
         }
        ]
      }
  }
Enter fullscreen mode Exit fullscreen mode

Now the children prop is an array of strings that contains another array of strings and so on. You might be confused by this example because in a real React.js app we use flat array of data and then transform it into elements using map method. But this example is also completely valid and I used it intentionally to show why ChildReconciler.instantiateChildren is necessary.

By the way, this is how the example above looks in JSX:


      <div>
        <h1>
          {[`We are building ${this.props.title}`, ['Are we?', 'Really?', ['Well...', 'That is impressive']]]}
        </h1>
        ...
      </div>

Enter fullscreen mode Exit fullscreen mode

Now let's look at the abstract version of the output of ChildReconciler.instantiateChildren:

{
  '.0.0': "We are building Reactjs",
    '.1:0.0': "Are we?",
    '.1:1.0': "Really?",
      '.1:2:0.0': "Well...",
      '.1:2:1.0': "That is impressive",
}
Enter fullscreen mode Exit fullscreen mode

Minimizing nesting is a common pattern in computer programming. This transformation will help not only to mount deeply nested children but also update them in the future.

ChildReconciler

Let's create ChildReconciler.js file and implement instantiateChildren in it.

// ChildReconciler.js

function instantiateChildren(children) {
  let childInstances = {};

  traverseAllChildren(children, instantiateChild, childInstances);

  return childInstances;
}
Enter fullscreen mode Exit fullscreen mode

instantiateChildren holds childInstances object and passes it by reference to traverseAllChildren function. Once traverseAllChildren function is finished and childInstances object is populated, it returns it.

We also pass instantiateChild function to traverseAllChildren function as the second parameter. This function just checks if a child is already present in the childInstances object. If it's not present (isUnique) then it passed to instantiateComponent to be mounted into DOM and the result is added into childInstances object. name here is a unique identifier of the child that is generated by depth in the tree (mount index) and parent. Real React.js library also uses key prop here.

function instantiateChild(childInstances, child, name) {
  let isUnique = childInstances[name] === undefined;

  if (isUnique) {
    childInstances[name] = instantiateComponent(child);
  }
}
Enter fullscreen mode Exit fullscreen mode

It's a little bit confusing here that instantiateChild function isn't declared in one file with traverseAllChildren and passed as a parameter but try to think of it in a way that traverseAllChildren takes childInstances object and instantiateChild function and use this function to populate childInstances object. Let's see how it happens:

// traverseAllChildren.js

const SEPARATOR = ".";
const SUBSEPARATOR = ":";

function traverseAllChildren(children, callback, traverseContext) {
  return traverseAllChildrenImpl(children, "", callback, traverseContext);
}

function traverseAllChildrenImpl(
  children,
  nameSoFar,
  callback,
  traverseContext
) {
  if (!Array.isArray(children)) {
    callback(
      traverseContext,
      children,
      nameSoFar + SEPARATOR + getComponentKey(children, 0)
    );
    return 1;
  }

  let subTreeCount = 0;
  let nextNamePrefix = nameSoFar === "" ? SEPARATOR : nameSoFar + SUBSEPARATOR;

  children.forEach((child, i) => {
    let nextName = nextNamePrefix + getComponentKey(child, i);
    subTreeCount += traverseAllChildrenImpl(
      child,
      nextName,
      callback,
      traverseContext
    );
  });

  return subTreeCount;
}

Enter fullscreen mode Exit fullscreen mode

As you can see traverseAllChildren just calls traverseAllChildrenImpl. The first thought that you might have is that "Why just not call traverseAllChildrenImpl directly?". It's called in such a way because traverseAllChildrenImpl actually calls itself with the second parameter nameSoFar being always different (empty string at first call and then string identifier for a child). Of course, there are ways to call it directly with preserved functionality but that's not the scope of this article.

Before trying to understand how this quite big function works let's stop at getComponentKey function. Note how the component parameter of the function is not used but the author of the original implementation still left it here. That's because here React.js library uses the key prop to generate a unique id but we will just use toString(36). Number 36 is a radix. For numbers from 0 to 9, it will give a normal string representation of the number. For greater numbers it will use the letters of the alphabet alongside numbers, for example for 55 it will be 1j:

  function getComponentKey(component, index) {
    return index.toString(36);
  }
Enter fullscreen mode Exit fullscreen mode

It's important to understand that traverseAllChildrenImpl function has two parts: one for non-array children and the second for array children. Also this function calls itself recursively and preserves subTreeCount alongside this calls by returning a number after each call.

subTreeCount is the counter for the number of children. We can't just use children.length here because as we saw earlier an array of children can contain subarrays.

Let's look at how traverseAllChildrenImpl processes our App class component:

type: "array"
nameSoFar: ""
parent: "div"
children: ["h1", "h2"]

type: "non-array" 
nameSoFar: ".0.0"
parent: "div" -> "array" // we can treat array also as a parent
children: "h1"

type: "non-array" 
nameSoFar: ".1.0"
parent: "div" -> "array"
children: "h2"

type: "array"
nameSoFar: ""
parent: "h1"
children: ["We are building Reactjs", ["Are we?", "Really?", ["Well...", "That is impressive"]]]

type: "non-array"
nameSoFar: ".0.0"
parent: "h1" -> []
children:  "We are building Reactjs"

type: "array"
nameSoFar: ".1"
parent: "h1" -> []
children:  ["Are we?", "Really?", ["Well...", "That is impressive"]]

type: "non-array"
nameSoFar: ".1:0.0"
parent: "h1" -> [[]]
children:  "Are we?"

type: "non-array"
nameSoFar: ".1:1.0"
parent: "h1" -> [[]]
children:  "Really?"

type: "array"
nameSoFar: ".1:2"
parent: "h1" -> [[]]
children:  ["Well...", "That is impressive"]

type: "non-array"
nameSoFar: ".1:2:0.0"
parent: "h1" -> [[[]]]
children:  "Well..."

type: "non-array"
nameSoFar: ".1:2:1.0"
parent: "h1" -> [[[]]]
children:  "That is impressive"
Enter fullscreen mode Exit fullscreen mode

I think that a developer who knows how to console.log a function properly has a great advantage in quickly understanding what this function does. You will have your opportunity to console.log this function in a codesandbox example at the end of this article.

Again, don't get frustrated about not understanding some parts, it will come with time, just return to the example from time to time.

Note that ':' in nameSoFar denotes the fact that we entered a subarray. And '.0' at the end means that it's the non-array child.

Notice also that when children argument is not an array it calls callback function which is instantiateChild function. It actually populates childInstances object that we get at the end.

// ChildReconciler.js

function instantiateChild(childInstances, child, name) {
  let isUnique = childInstances[name] === undefined;
  if (isUnique) {
    childInstances[name] = instantiateComponent(child);
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see we call instantiateComponent from instantiateChild. We know that instantiateComponent handles two types of elements:

  1. Elements that has typeof type === 'string' (e.g. { type: 'div', props: ... });
  2. Elements that has type field pointed to class: { type: App, props: ... };

But now as you can see we added another type of element which is a simple string, e.g. an element of the array ["Are we?", "Really?", ... ]. So now we need to also handle this type of elements:

// Component.js 

// before
function instantiateComponent(element) {
  let wrapperInstance;
  if (typeof element.type === "string") {
    wrapperInstance = HostComponent.construct(element);
  } else {
    wrapperInstance = new element.type(element.props);
    wrapperInstance._construct(element);
  }

  return wrapperInstance;
}

// after
function instantiateComponent(element) {
  let wrapperInstance;
  if (typeof element.type === "string") {
    wrapperInstance = HostComponent.construct(element);
  } else if (typeof element.type === 'function') {
    wrapperInstance = new element.type(element.props);
    wrapperInstance._construct(element);
  }  else if (typeof element === 'string' || typeof element === 'number') {
    wrapperInstance = HostComponent.constructTextComponent(element);
  }

  return wrapperInstance;
}
Enter fullscreen mode Exit fullscreen mode

As you can see, typeof element === 'string' || typeof element === 'number' checks if elements is a simple string or number and then we call HostComponent.constructTextComponent. Let's add this function to HostComponent file:

// HostComponent.js
function constructTextComponent(element) {
  return construct({
    type: "span",
    props: {
      children: element
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Basically, every string and number is wrapped in a span element using construct function which calls DOMComponentWrapper wrapper class.

Rendering

We are almost finished. Now let's go to the place where all this started.

// MultiChild.js

class MultiChild {
  mountChildren(children) {
    const renderedChildren = ChildReconciler.instantiateChildren(children);
    this._renderedChildren = renderedChildren;

    return Object.keys(renderedChildren).map((childKey, i) => {
      let child = renderedChildren[childKey];
      child._mountIndex = i;

      return Reconciler.mountComponent(child);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

After we've got renderedChildren flat object we transform it into an array of keys [".0.0", ".1:0.0", ...], iterate over it and assign this keys to the respective child's property _mountIndex. We will use _mountIndex in the future when we perform updates. Then we call Reconciler.mountComponent with each child to get DOM element and all this is getting returned return Object.keys(renderedChildren) to the DOMComponentWrapper's _createInitialDOMChildren method and assigned into mountImages variable in the form of an array of DOM elements - [span, span, span, ...]

// DOMComponentWrapper.js

_createInitialDOMChildren(props) {
    if (typeof props.children === "string") {
      this._domNode.textContent = props.children;
    } else if (Array.isArray(props.children)) {
      let mountImages = this.mountChildren(props.children);
      DOM.appendChildren(this._domNode, mountImages);
    }
  }
Enter fullscreen mode Exit fullscreen mode

Now we can append these DOM elements into our root node:

// DOM.js

function appendChildren(node, children) {
  if (Array.isArray(children)) {
    children.forEach(child => appendChild(node, child));
  } else {
    appendChild(node, children);
  }
}
Enter fullscreen mode Exit fullscreen mode

That's it, we did it!

Alt Text

This is a flowchart of what we've covered so far

Feel free to open it in the second tab/monitor and go through this article again.

Now if you think that it was difficult, hold on! We are going to implement update logic in the following articles.

Links:

  1. Github repo with the source code from this article
  2. Codesandbox with the code from this article
  3. Building React From Scratch talk
  4. React.js docs regarding Building React From Scratch talk

Discussion (0)