Build your own React – Episode VI

Recap and comparison with the real React.
Retro cars in front of yellow garage

Cover photo by Dietmar Becker – two cars in front of shutter doors

Welcome back to the Babbel Bytes series on “Build your own (simple) React from scratch!”. In this last episode of the series based on a workshop originally created for React Day Berlin 2022 we will draw some comparisons between our implementation and the real React implementation.

We have gone through the whole flow of creating our own React, supporting rendering components, with a VDOM implementation, reconciliation and the main hooks useEffect and useState. That’s amazing!

Now if you remember, in the first article we described the approach we took to come up with the code as thinking about React from the outside, but not actually getting inspiration from how it’s coded.

It’s time to now compare the decisions we took compared to the real React to see the differences and understand them.

Incomplete feature set

The first glaring difference between our own React and the real React is the feature disparity.

Our own version of React only really covers the most important part to get to a functioning app, but we are missing quite a lot of features.

Those features can be bucketed in two parts:

  1. Legacy features – React still supports these to make code migration easier for users,  e.g. class components, or createElement APIs. We believe that those features, if React were to be created today, wouldn’t be part of the API, but because of the long history of React, those features still exist.
  2. Current features – less important than those we implemented, but are absolutely essential for more complex scenarios. We will highlight a few of those below.

In the hooks category, the most important hooks that we haven’t implemented are:

Ref hooks: the useRef hook enables a lot of useful use cases without which a lot of existing apps wouldn’t function. It is an essential hook to be able to keep in memory data that isn’t directly useful for rendering, for example:

  1. References to DOM elements, so that it is possible to run code that can only be run through JavaScript, for example manipulating a canvas element.
  2. References to timeout or requestAnimationFrame IDs, so that they can be canceled when needed but do not cause useless renders.

Context API: the context API is a powerful React interface that enables developers to share information within a component’s tree.

Less important hooks: There is also an array of hooks designed to let developers fine-tune and improve performance of their components, such as useMemo, or the more recent useTransition and useDeferedValue.

There are also convenience hooks which enhance existing hook behaviors for common scenarios: such as useReducer with useState, or useCallback with useMemo.

There are also a few more, less often used, hooks, for a full list you can refer to React’s official documentation.

In the Components category, we haven’t created any ourselves for our own version of React. Here Fragment would be essential to add as it allows to group multiple elements without having a side-effect on the DOM, this is very useful for example when you want a group of element styles’ to be based on their parent element in the DOM. Suspense is a big ticket item too, which we will talk about in the next section. The other components help to improve developer’s experience.

Server side React is also a big ticket item we haven’t implemented, we have a dedicated subsection about that at the end of this article.

Finally there are a few functions labeled as APIs that work together with some of the hooks mentioned before or are related to performance improvement (lazy).

Performance: React Fiber

In this article series, we took a relatively simple and primitive approach to our rendering. On the initial rendering, we render the component tree, create a VDOM representation of it, and finally flush the parts that apply to the DOM in the DOM handlers. Then, for every subsequent state update, we do the following steps:

  1. Rerender the whole VDOM with the new state value
  2. Compare the previous VDOM to the current VDOM to compute the minimal set of changes to be added to the actual DOM
  3. Apply the diff computed in step 2

This is a working approach, but it has a few flaws in terms of performance that could become visible in scenarios where we would encounter a lot of state updates in a short period of time.

For example, let’s imagine we were creating a UI that allows a user to search for their favorite movie.

On every keystroke, the app will do a full text search amongst titles of the movies it has in memory, then rerender the list of movies matching the text of the search input.

If a user starts typing fast enough to the point that they type faster than the update cycle (filtering of the list and rendering of the newly filtered list), our UI will start to noticeably lag – not a great user experience!

Furthermore, it is likely that there will be intermediate results that the users weren’t really interested in (e.g. if I wanted to search for “Lord of the Rings”, I’ll probably not care much about the result for “L”, “Lo”, “Lor”, and perhaps “Lord” is still too broad hence I don’t care about it either), so we will start wasting resources for no good reason.

Even worse, the actual input will also be affected by the lag, so the user will actively be prevented from typing the next letters until the current rendering has finished and the browser is again free to process. That is because the JavaScript event loop is single-threaded, and we don’t use any techniques in our code to be able to interrupt the work in case the current update becomes obsolete. Such code in general is deemed “blocking”, meaning as long as it hasn’t been completed, nothing else will be able to happen.

There are ways for developers using our React library to alleviate some of those pains. In the case of the user input specifically, the developers could choose to use debouncing (only acting upon the input changes after no event has occurred for a determined lapse of time, to avoid intermediary results that are of less interest to our users) to keep performance good. It’s not the greatest though because it forces developers using our library to be aware of some of the limitations of our implementation.

And there could be trickier cases to cover for them too!

So how does the official React library deal with such a problem?

They use an architecture called “React Fiber” which is a foundation for React’s concurrent mode.

A fiber is a data structure used by React, it is basically a representation of a JSX element with a few extra things to help React work with it.

A fiber’s most important properties are:

  • The tag, which represents the category of the element (e.g. a FunctionComponent or a HostComponent for DOM elements in react-dom)
  • The type of element, for example the component’s function for FunctionComponent or its class for ClassComponent.
  • The stateNode which holds the current state of the actual element (e.g. the DOM element for a HostComponent)
  • The child is used to track the first child of an element
  • The sibling is used to track the next sibling of an element, together with child this allows to have a linked list of fibers to represent the VDOM tree
  • The return is used to go backwards through the list (so that it’s possible from a Fiber to go up the tree) in general it is the parent of the element, it is conceptually the same as the return address in a stack frame

To help make better sense of this, here is a diagram showing a VDOM tree and its equivalent Fiber tree.

Representation of a VDOM tree.
At the top level, the App component has one child, a Counter component.
The Counter component has two children: a button and a div.
The button has one child: "+".
The div has one child: "0".
Representation as a Fiber of the VDOM tree above.
At the top level, there is now the HostRoot (the top level fiber, React's insertion point in the DOM).
Going down, the child of the HostRoot is the App component.
Its tag is FunctionComponent, its type is the App function and its stateNode is null.
Its return is the HostRoot.
The App has one child, the Counter component.
The Counter component return is the App.
The Counter component has a button as child.
The button's tag is HostComponent, its type is 'button' and its stateNode is is the HTMLElement of the button.
Its return is the Counter component.
It has one child, a '+'.
The '+' return is the button.
The button has a sibling, the div.
The div has a child '0'.
The '0' return is the 'div'.
The 'div' return is the Counter.

As mentioned, the goal of the fiber architecture is to enable React to perform updates in a less blocking, efficient way.

To do so, React keeps track of two fiber trees: the current and the work in progress tree.

At first, there will only be a work in progress tree, while React prepares to render for the first time.

Once the work in progress tree’s work is completed, it will become the current tree.

When an update will be required afterwards, React will make a clone of the current tree that will be the work in progress tree, and it will start working to complete that work.

Once the work is completed, that work in progress tree becomes the current tree, and so the cycle continues.

During an update, React needs a way to retrieve the Fiber corresponding to the current work in progress one in the current tree (a.k.a the tree rendered to the DOM currently).

IIn our solution, we used VDOM pointers to that effect, React instead uses a property set on the Fiber, the alternate, which points to its corresponding Fiber in the current tree.

Representation of the two fiber trees used by React, showing the alternate for each Fiber.
Each tree looks similar, and in the work in progress tree, each fiber has a link to its alternate in the current tree.

Alright, now that we have fibers explained, let’s see how they help React handle updates in an asynchronous and interruptible way.

To achieve this, React will work on each Fiber one by one, starting at the root, and will regularly give a chance to the browser to do some work (instead of hogging the main thread like we did).
To further split the work into very manageable chunks, React has split the work to be done on each fiber into three steps:

  • beginWork which does the initial step of the work: for example, a function component will be called to get its children, then recursively step into children and siblings to continue the work. Once a leaf node (a.k.a. a fiber with no child or sibling) is found, that Fiber will move to the next step: completeWork.
  • completeWork, in the case of react-dom, creates the DOM elements in memory and keeps them in the fiber to be updated in the actual DOM later. It also keeps track of whether a Fiber got an update during this render phase so that React can later commit those changes to the DOM. Once a fiber completeWork is called, it’ll schedule as the next work to be completed its parent fiber (the return).
  • commitWork, called once all Fibers in the current tree have completed their work, will go through all fibers recursively starting at the root. Work stops once a Fiber is encountered for which there are no updates in its subtree or once all the updates have been committed to the DOM.

If you are interested in a deeper dive into this topic, we recommend watching this great video with Dan Abramov digging into the React codebase to implement a new mode, explaining some of those concepts along the way.

This architecture alone doesn’t explain how it fixes the points raised above, but React has a final ace up its sleeves: a Scheduler.

The role of the Scheduler is to schedule the work to be done by Fibers, and the architecture above allows it to go through the fiber’s update steps one by one and schedule them.

This enables React to:

  • Avoid blocking the main thread, as the scheduler plans small unit of works from Fibers to be done and gives back the control to the browser after each is completed, allowing the browser to perform any outstanding updates (e.g. CSS animations, input processing, timeout callbacks, network request handling…)
  • Have different priorities based on the type of update taking place, so that an update based on a user input can be prioritized over a part of the page being rerendered

Aside from the performance aspect, an interesting difference between the architecture of our own React VS the real React is the way the core components architecture is decoupled from the rendering specific to platform (aka react-dom).

In our own architecture, we have decoupled the mechanisms by having the “core react” only compute diffs that explain how to render elements, and the DOM handlers only know how to apply those computed diffs.

In React’s architecture, the core architecture receives a Renderer, and this renderer must conform to a specific interface, so that React core can call the right functions to perform updates along the way in the Fiber’s update steps (begin, complete, commit).

We would probably not have been able to write this section if it wasn’t for the amazing A cartoon intro to React Fiber from Lin Clark, we strongly recommend having a look at that amazing talk!

What’s the diff between our diff and React’s diff, do they have beef?

In our own React, we took the position that our core React would know as little as possible about the rendering engine, instead focusing solely on creating trees and diffs that could be given to the renderer.

In React, the approach is slightly different. The core is still not really aware about how exactly rendering works, but instead of it being functions that output data that can be read by a Renderer, it is provided with the Renderer during initialization and calls that renderer’s methods at appropriate times.

This architecture can enable the core React to already create DOM elements (through the use of the provided DOM renderer) while the VDOM is being created, which allows it to do some DOM manipulation work early on.

This difference is also visible on the diffing side.

Each Fiber contains the work to be done to the DOM, for example mutations to be applied.

During the complete work phases of each Fiber, the needed mutations are computed and queued.

Then during the commitPhase, those mutations are flushed to the DOM.

The advantage of this mutation approach is performance (or so we believe). With our approach, it is required we keep a map of VDOMPointer to DOM element so we can find those references back. On every update, we are also required to go through quite a few loops: first we loop through the VDOM when applying the new hooks state, then we loop through old VDOM vs new VDOM to compute the diff, finally we loop through the calculated diffs to apply them to the actual DOM, React only needs one of those loops.

Our approach has allowed us to really nicely separate the different parts of the rendering and leads to what we hope is clearer code than if we had mixed it all up and used mutations, but it is definitely less efficient as a result.

React, on the other hand, achieved similar separations with the injected Renderer and then separated the work into phases (a bit like our VDOM rendering followed by our diffing) to achieve better performance while maintaining readability.

An interesting problem we faced that the official React faced too involved looping through the tree to find back references to DOM elements when needing to insert/remove new tree elements where the parent is not a DOM element.

In our case, you may remember we used a recursive algorithm to compute rendering and diffing.

In React, a decision was made to avoid recursion and instead use loops because the developers were afraid the call stack might prove otherwise problematic for very big applications.

This leads to slightly complex loops to read/write that you can see an example of here in React’s codebase.

As this is quite a lot of code to go through, let’s break it down to just the tree traversal first:

// Start at the first child
let node = workInProgress.child;
// As long as we have a node
while (node !== null) {
 doSomethingToChildOrSibling(node);
 // If the current node has a child, use that child as current
 if (node.child !== null) {
   // Set return as current node, this allows to first go through the children, and then later on go back to the return node, so that siblings can be processed (depth-first traversal 😉)
   node.child.return = node;
   node = node.child;
   continue;
 }
 // If we are back at the first node, we are done with the loop so we can return
 if (node === workInProgress) {
   return;
 }
 // As long as the current node does not have siblings
 while (node.sibling === null) {
   // If we don’t have a return or the current node is back to the first one, we are done with the loop and we can return
   if (node.return === null || node.return === workInProgress) {
     return;
   }
   // Otherwise, make the current node the parent node of the current node
   node = node.return;
 }
 // Set up the return node for the sibling so we can go back up later to finalize exploring
 node.sibling.return = node.return;
 // Make current node the sibling so we can continue traversing
 node = node.sibling;
}

A few complex problems we ran into during implementation had to do with the discrepancy between the VDOM structure not exactly matching the DOM one, as the VDOM also contains references to elements that do not render to the DOM like components.

This forced us to come up with a mechanism to deal with this problem: when deleting/adding nodes, we sometimes have to backtrack the tree until we find where to add/remove from.

React has the same problem! And actually, the function we just linked to deals with that exactly.

If we look at the real code that we simplified to doSomethingToChildOrSibling in the snippet above:

if (node.tag === HostComponent || node.tag === HostText) {
 appendInitialChild(parent, node.stateNode);
}

Here, a HostComponent is a component that the host system knows how to render (so for ReactDOM, a DOM element, such as a div).

A HostText is a text component.

So this snippet appends the node.stateNode to the parent if that node is renderable to the host system (so the DOM for us).

The node.stateNode is the rendered element representing the Fiber React is currently processing, for example a “Hello World” text node.

Then if we put it all back together, the function is looping through all children, then siblings, of a specific Fiber.

For each of them:

  • If the node is renderable to the DOM, create it and append it to its parent
  • If the node isn’t renderable, then React traverses its children and siblings to find if any of them are renderable and renders them

You may recall we had to do similar tricks in our own React too:

  • For insertion of a new node, we kept around a parentPointer, which is the VDOM pointer to the parent of the node to be inserted within the DOM (so if there are non host components in the tree of this node, we can still find the actual host component to append to)
  • For deletion of a node, we require special code in case the removed node isn’t a host component in order to find its top-level rendered children so we can properly remove them from the DOM.

As you can see, the approaches taken by the real React versus our own version are required to deal with the same problem in a somewhat similar way, yet the approaches are completely flipped.

Implementation detail: hooks handling

In our own implementation, we faced the problem of the hooks API needing two aspects that initially seem contradictory:

  1. The hooks are exposed globally through import { useHook } from ‘react’;
  2. Each hook call must be tied to the instance of the component it is rendered from for them to work properly

So a solution must be found for a globally exposed hook to be somehow tied to a specific component’s instance.

In our solution, we decided to initially expose a dummy function for each hook and replace those dummy functions during the component rendering to fill in the right execution context.

How does the actual React code deal with this? 🕵️‍♀️

Well, in this instance, it is very similar to what we did.

Each hook is defined as a function, for example useState, that will use a function resolveDispatcher to get a dispatcher and then call this dispatcher’s hook.

The resolveDispatcher function itself accesses a module variable ReactCurrentDispatcher and accesses its current value. During development, it has the additional functionality of emitting the hook call outside of a component warning.

If we continue going deeper down in the implementation, the ReactCurrentDispatcher is just a ref-like object (it holds a current value that should be replaced by the right value).

If we look for ReactCurrentDispatcher it is then exposed by the react package in the SharedInternals.

Finally, it’ll be set in a function renderWithHooks to different values based on the update cycle and whether the app currently runs in development mode.

Ignoring the development mode differences, as we didn’t really pay attention to that for the sake of our exercise, there is still an interesting difference in production mode:

ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;

First some explanation of the context of this code snippet.

It is triggered during the rendering of a Fiber.

The current value holds the previous value for that Fiber (so on the first render, null), then based on whether the Fiber is rendered for the first time will assign a different dispatcher, either the OnMount one or the OnUpdate one.

The mystery unravels, but what kind of difference does React make between on mount and on update?

Continuing down the rabbit hole, we can find the OnMount dispatcher, following just useState, the OnMount dispatcher sets its value to mountState.

The mountState function is creating a hook variable that contains a few properties used for React Fiber, but for the most interesting parts for our comparison, it does a similar job to what we did:

  1. Handles the initialState by assigning it to the hook but making sure first to call it if it’s a lazy initialization
  2. Creates a setState function binding its output to the right context
  3. Returns the array of [value, setState]

If you look closely at the code, you might see that the creation of the setState function is much more complicated than what we had. Mostly, this boils down to details of the Fiber implementation and we will not dig into those details now.


Next let’s have a look at the OnUpdate dispatcher, similar to its OnMount counterpart, it also sets all its hooks to functions.

More specifically, it sets useState to an updateState function.

The updateState function itself just does one thing, returning the result of calling updateReducer with a basicStateReducer and the initialState.

From here on, things start to drift a bit apart compared to our implementation!

In our case, we would always recreate the useState function based on the context of the current component, hence we could provide it with the details of the current component during its creation.


In React, instead, the updateReducer function (which is called by useState) will retrieve the information about the currently rendering component instance by calling an updateWorkInProgressHook function.

Then it calls the updateReducerImpl function with this hook, as well as the basicStateReducer.

The updateReducerImpl function is, similar to the setState function, more complex due to the Fiber architecture and the asynchronous nature.

For the sake of simplicity, we will ignore two parts of what React does here.

The first part we ignore is React figuring out what update should happen, this is set in the setState function and is retrieved from a shared variable (the baseQueue part of the code).

The second part is React deciding whether the priority of applying the action, React uses a few heuristics and priority rules to decide what updates are the most urgent.

This allows us to focus only on the actual work of updating the state which can mostly be boiled down to the following pieces:

  1. The computation of the next state: newState = reducer(newState, action). * (See clarifications below)
  2. The assignment of the next state as the current state.
  3. And finally returning the array [currentValue, setValue].

* Let’s decode that first step that looks a bit weird including what each variable is set to:

  • The newState on the left will be the new state after the update is finalized
  • The reducer is the basicStateReducer
  • The newState provided to it is the current state of the hook
  • The action is the value provided to setState by the app developers (either an updated state, or the state setter function)

The basicStateReducer checks whether the action provided is a function. If that is the case, it calls the function with the current state as an argument (that’s our function setState update) or just returns the action otherwise (as it’s our new value).

So this looks very similar to our own implementation if we ignore the implementation detail of using a reducer for the simple state update.

BUT WHY THOUGH? If I had to guess, the idea was to increase maintainability. React needs to support the useReducer hook, and most of the complexity in both useState and useReducer in React’s implementation boil down to managing asynchronous updates. Sharing this complex implementation, at the price of having a slightly weird looking reducer, seems like a nice way to manage the complexity and share code between the two hooks.

So all in all for the setState hook, our implementations aren’t that far apart.

React also relies on module shared variables to enable the use of hooks.

Then, when it comes to the hook’s implementation itself, the difference is a bit starker. We went for simply passing down the current hook value based on the component being rendered, whereas React has a more complex process relying on a queue of updates to be performed. Ultimately though, this difference is mostly brought on by different implementation goals: we wanted simplicity and React wanted performance and the ability to compute asynchronously/concurrently.

Finally, when it comes to the core of useState, while both implementations follow the same API, the differences again are quite remarkable. React’s implementation has fully separate implementations between mounting and updating whereas ours only uses local if/else to deal with it. And React uses code from useReducer to perform useState updates while we simply update the state directly.

DOM APIs

Ultimately, the beauty of both React and our own implementation using web technologies is at the very core, the code will look similar and use the exact same APIs.

Here are a few examples of our code and React using the exact same APIs:

For DOM manipulation

For manipulating elements

Appending a child with appendChild

React:

export function appendChild(
 parentInstance: Instance,
 child: Instance | TextInstance,
): void {
 parentInstance.appendChild(child);
}

Our own code:

childrenAsDomElement.forEach(childElement => {
     if (childElement) {
       domElement.appendChild(childElement);
     }
   });

Inserting a child with insertBefore: React and our own code

Creating a text node with createTextNode: React and our own code

Updating a text node by updating its nodeValue: React and our own code

Removing a node with removeChild: React and our own code

For manipulating attributes

For setting props as attributes:

React:

export function setValueForKnownAttribute(
 node: Element,
 name: string,
 value: mixed,
) {
 if (value === null) {
   node.removeAttribute(name);
   return;
 }
 switch (typeof value) {
   case 'undefined':
   case 'function':
   case 'symbol':
   case 'boolean': {
     node.removeAttribute(name);
     return;
   }
 }
 if (__DEV__) {
   checkAttributeStringCoercion(value, name);
 }
 node.setAttribute(
   name,
   enableTrustedTypesIntegration ? (value: any) : '' + (value: any),
 );
}

Our own code:

const applyPropToHTMLElement = ({ key, value }, element) => {
 if (isEventHandlerProp(key)) {
   addEventHandler(element, { key, value });
   return;
 }
 if (element[key] !== undefined) {
   element[key] = value;
 } else {
   element.setAttribute(key, value);
 }
};




const removePropFromHTMLElement = ({ key, oldValue }, element) => {
 if (isEventHandlerProp(key)) {
   removeEventHandler(renderedElementsMap[VDOMPointer], { key, oldValue });
   return;
 }
 element.removeAttribute(key);
};

You can see here the code is slightly different to ours, as it takes care of:

  • the removal of value when value is null (you can see we instead have a specialized version only for removing attributes) or something the DOM doesn’t understand such as functions or objects (we did not handle that case)
  • casting the value to a string in case it isn’t one, as that’s what the DOM wants (we have relied on the browser doing that)

You might remember we also had some troubles with attributes setting (here in the code or described in the first article), specifically for input where the value attribute couldn’t be easily reset with the setAttribute API.

This is handled a bit differently in React, but ends up having similar quirks to our solution.

The updating of props for input elements is handled as a separate case.
Most props of the input will be set in the traditional way React does as above with setAttribute.

But some props are a special case such as value as can be seen here.

For those, React will call a special function dedicated to correctly updating such attributes for an input.

Finally, on the props side, you might remember event listeners were also a special case because they are required to be added through addEventListener and removed with removeEventListener.

React event handling is done very differently from our solution as React has its own event listening system with synthetic events, but the fun part is we can still see some code that looks very similar to ours.

For example, you may remember the quirk that when setting a new event listener, we had to remove any previously set event listener.

React does the same where they make sure to remove any existing event listener before setting the new one

Server side rendering

Server side rendering (SSR) is a feature of React that allows it to render a React app in a JavaScript environment outside the browser (for example Node.js). Traditional client-side React rendering first renders empty HTML until the JavaScript loads, then React renders the app and flushes the changes to the DOM. SSR, on the other hand, delivers a full HTML page to the browser before React is loaded. This is particularly useful to improve SEO, as it allows search engines crawlers to access and index the content of your site without needing to run JavaScript.

Server side rendering has seen a surge in interest recently with the announcement of React Server Components, React recommending Next.js, Remix or Gatsby as good starting points or the fast growth of frameworks geared towards creating multi-page applications like Astro.

For server side rendering in React to work correctly, an application needs to go through two stages. First, the application is rendered on the server which creates the initial HTML rendered by the app.

Then, this HTML is sent to the browser for rendering. For applications with client-side interactions and updates, for example the TODO app we built as an example, JavaScript is required for the components so that React can rehydrate. Specifically, hydration involves recreating the picture of the React tree from initial rendering in memory and reconciling with the HTML generated by the server, taking care of elements of interactivity that can only happen in the browser such as attaching event handlers.

If you are interested to learn more about those topics, you can find a lot more in Eric’s series on web rendering.

So could our own version of React support server side rendering? Absolutely!

In terms of the advantages of our architecture, we can note the fact we completely isolated code meant to work in the browser into our DOM-handlers and the code that is purely related to components rendering in our core. Our core is pure JavaScript and does not include any browser-specific code, so it would be able to run in a Node.js environment already.

So to support the first step of server side rendering, being able to render in Node, we would just need to create a version of DOM-handlers that can create elements as strings instead of using browser’s API to do so. This would be the trivial part of adding server side rendering. It would only require creating a renderElementToHtml version, that instead of using document.createTextNode, document.createElement and appendChild, would replicate their behavior only returning the HTML as a string.

On the other end, the second part would not be as easy. We do not currently have any tools to reconcile a server-rendered HTML page with a VDOM in the browser. Our diffing algorithm compares VDOM to VDOM and is not capable of looking into an existing DOM to create a diff.

This means, our DOM-handlers would have to become a bit more complex, and support a new method like React’s hydrateRoot. This method would render the provided JSX, rendering its VDOM in memory.

Next, it would need to walk through the existing DOM within the root in the browser (which would be the HTML rendered by the server), and for each DOM node, compare whether it matches what we expected from our VDOM.

At the same time, it would be expected to take care of what the server couldn’t do: browser specific APIs, in our case attaching event listeners.

Finally, if finding any mismatch, our React would have to take a decision as to what to do.

The simplest and safest approach is to destroy the generated DOM and replace it by our client-side generated VDOM, this is unfortunately not ideal, as it means the work done by the server is thrown away, and it can lead to very poor user experience where the UI would flicker after the client-side hydration (which would at this point still be rendering).

A more complex but preferable approach would be to find ways to reconcile problems locally and only replace/destroy smaller subtrees, but this requires better heuristics of diffing to be safe.

React implements some heuristics to attempt to fix problems when the server side rendered HTML doesn’t match the client-side picture, but even then it is not perfect. A nice picture of this is painted in Josh Comeau’s article about hydration problems.

We also mentioned React Server Components above, this would be one more layer which we would need to replicate and it includes some tricky bits, such as letting the client-side rendered app know that some components within its HTML were rendered on a server by a server component, hence it shouldn’t modify those as it doesn’t have the code to do it.

Conclusion

And that’s a final wrap! 🎁 We went through implementing some of the most important aspects of React, and as you can see we have done a lot of implementation and architecture choices quite different from how React actually works!

That is to be expected, React is more than 10 years old, is now in its 18th version, has more than 1500 contributors, 16000 commits and a huge community of passionate developers. On the other hand, our own React was developed by three musketeers 🤺, in a few months, during learning time, with the intention of being as simple as we could make it so that we can hopefully go through the implementation in a 4-hour workshop!

Nonetheless, it is quite instructive to see how one can recreate a library as ambitious as React when you don’t need to be burdened with existing usage, backwards compatibility, or performance 😉

We hope you learnt a few useful tips for your everyday work, and that this gave you a bit of inspiration to take over that challenging project you have been putting off! 

If you can reimplement React, surely the sky’s the limit now! 🌌

Sources used during the writing of this article:

SMOOSHCAST: React Fiber Deep Dive with Dan Abramov
Why, What, and How of React Fiber with Dan Abramov and Andrew Clark

Lin Clark – A Cartoon Intro to Fiber – React Conf 2017

React Fiber Architecture – Andrew Clark


This article was written as a trio in collaboration with Ömer Gülen and Sean Blundell.

Share: