Build Your Own React.js in 400 Lines of Code

In-depth study of React principles

React v19 beta has been released. Compared to React 18, it offers many user-friendly APIs, though its core principles remain largely unchanged. You might have been using React for a while, but do you know how it works under the hood?

This article will help you building a version of React with about 400 lines of code that supports asynchronous updates and can be interrupted—a core feature of React upon which many higher-level APIs rely. Here is a final effect Gif:

I used the tic-tac-toe tutorial example provided by React’s official website and can see that it works well.

It is currently hosted on my GitHub, and you can also visit the Online Version to try it out for yourself.

JSX and createElement

Before diving into the principles of mini-react.ts, it's important to understand what JSX represents. We can use JSX to describe the DOM and easily apply JavaScript logic. However, browsers don’t understand JSX natively, so our written JSX is compiled into JavaScript that browsers can understand.

I used babel here, but of course you can use other build tools and the content they generate will be similar.

So you can see that it calls React.createElement, which provides the following options:

  1. type: Indicates the type of the current node, such as div.

  2. config: Represents the attributes of the current element node, for example, {id: "test"}.

  3. children: Child elements, which could be multiple elements, simple text, or more nodes created by React.createElement.

If you are a seasoned React user, you might recall that before React 18, you needed to import React from 'react'; to write JSX correctly. Since React 18, this is no longer necessary, enhancing developer experience, but React.createElement is still called underneath.

For our simplified React implementation, we need to configure Vite withreact({ jsxRuntime: 'classic' }) to compile JSX directly into the React.createElement implementation.

Then we can implement our own:

// Text elements require special handling.
const createTextElement = (text: string): VirtualElement => ({
  type: 'TEXT',
  props: {
    nodeValue: text,
  },
});

// Create custom JavaScript data structures.
const createElement = (
  type: VirtualElementType,
  props: Record<string, unknown> = {},
  ...child: (unknown | VirtualElement)[]
): VirtualElement => {
  const children = child.map((c) =>
    isVirtualElement(c) ? c : createTextElement(String(c)),
  );

  return {
    type,
    props: {
      ...props,
      children,
    },
  };
};

Render

Next, we implement a simplified version of the render function based on the data structure created earlier to render JSX to the real DOM.

// Update DOM properties.
// For simplicity, we remove all the previous properties and add next properties.
const updateDOM = (DOM, prevProps, nextProps) => {
  const defaultPropKeys = 'children';

  for (const [removePropKey, removePropValue] of Object.entries(prevProps)) {
    if (removePropKey.startsWith('on')) {
      DOM.removeEventListener(
        removePropKey.substr(2).toLowerCase(),
        removePropValue
      );
    } else if (removePropKey !== defaultPropKeys) {
      DOM[removePropKey] = '';
    }
  }

  for (const [addPropKey, addPropValue] of Object.entries(nextProps)) {
    if (addPropKey.startsWith('on')) {
      DOM.addEventListener(addPropKey.substr(2).toLowerCase(), addPropValue);
    } else if (addPropKey !== defaultPropKeys) {
      DOM[addPropKey] = addPropValue;
    }
  }
};

// Create DOM based on node type.
const createDOM = (fiberNode) => {
  const { type, props } = fiberNode;
  let DOM = null;

  if (type === 'TEXT') {
    DOM = document.createTextNode('');
  } else if (typeof type === 'string') {
    DOM = document.createElement(type);
  }

  // Update properties based on props after creation.
  if (DOM !== null) {
    updateDOM(DOM, {}, props);
  }

  return DOM;
};

const render = (element, container) => {
  const DOM = createDOM(element);
  if (Array.isArray(element.props.children)) {
    for (const child of element.props.children) {
      render(child, DOM);
    }
  }

  container.appendChild(DOM);
};

Here is the online implementation link. It currently renders the JSX only once, so it doesn’t handle state updates.

Fiber architecture and concurrency mode

Fiber architecture and concurrency mode were mainly developed to solve the problem where once a complete element tree is recursed, it can't be interrupted, potentially blocking the main thread for an extended period. High-priority tasks, such as user input or animations, might not be processed timely.

In its source code the work is broken into small units. Whenever the browser is idle, it processes these small work units, relinquishing control of the main thread to allow the browser to respond to high-priority tasks promptly. Once all the small units of a job are completed, the results are mapped to the real DOM.

And in real React, we can use its provided APIs like useTransition or useDeferredValue to explicitly lower the priority of updates.

So, to sum up, the two key points here are how to relinquish the main thread and how to break down work into manageable units.

requestIdleCallback

requestIdleCallback is an experimental API that executes a callback when the browser is idle. It's not yet supported by all browsers. In React, it is used in the scheduler package, which has more complex scheduling logic than requestIdleCallback, including updating task priorities.

But here we only consider asynchronous interruptibility, so this is the basic implementation that imitates React:

// Enhanced requestIdleCallback.
((global: Window) => {
  const id = 1;
  const fps = 1e3 / 60;
  let frameDeadline: number;
  let pendingCallback: IdleRequestCallback;
  const channel = new MessageChannel();
  const timeRemaining = () => frameDeadline - window.performance.now();

  const deadline = {
    didTimeout: false,
    timeRemaining,
  };

  channel.port2.onmessage = () => {
    if (typeof pendingCallback === 'function') {
      pendingCallback(deadline);
    }
  };

  global.requestIdleCallback = (callback: IdleRequestCallback) => {
    global.requestAnimationFrame((frameTime) => {
      frameDeadline = frameTime + fps;
      pendingCallback = callback;
      channel.port1.postMessage(null);
    });
    return id;
  };
})(window);

Here’s a brief explanation of some key points:

Why use MessageChannel?

Primarily, it uses macro-tasks to handle each round of unit tasks. But why macro-tasks?

This is because we need to use macro-tasks to relinquish control of the main thread, allowing the browser to update the DOM or receive events during this idle period. As the browser updates the DOM as a separate task, JavaScript is not executed at this time.

The main thread can only run one task at a time — either executing JavaScript or processing DOM calculations, style computations, input events, etc. Micro-tasks (e.g., Promise.then), however, do not relinquish control of the main thread.

Why not use setTimeout?

This is because modern browsers consider nested setTimeout calls more than five times to be blocking and set their minimum delay to 4ms, so it is not precise enough.

Algorithm

Please note, React continues to evolve, and the algorithms I describe may not be the latest, but they are sufficient to understand its fundamentals.

Here's a diagram showing the connections between work units:

In React, each work unit is called a Fiber node. They are linked together using a linked list-like structure:

  1. child: Pointer from the parent node to the first child element.

  2. return/parent: All child elements have a pointer back to the parent element.

  3. sibling: Points from the first child element to the next sibling element.

With this data structure in place, let's look at the specific implementation.

We're simply expanding the render logic, restructuring the call sequence to workLoop -> performUnitOfWork -> reconcileChildren -> commitRoot.

  1. workLoop : Get idle time by calling requestIdleCallback continuously. If it is currently idle and there are unit tasks to be executed, then execute each unit task.

  2. performUnitOfWork: The specific unit task performed. This is the embodiment of the linked list idea. Specifically, only one fiber node is processed at a time, and the next node to be processed is returned.

  3. reconcileChildren: Reconcile the current fiber node, which is actually the comparison of the virtual DOM, and records the changes to be made. You can see that we modified and saved directly on each fiber node, because now it is just a modification to the JavaScript object, and does not touch the real DOM.

  4. commitRoot: If an update is currently required (according to wipRoot) and there is no next unit task to process (according to !nextUnitOfWork), it means that virtual changes need to be mapped to the real DOM. The commitRoot is to modify the real DOM according to the changes of the fiber node.

With these, we can truly use the fiber architecture for interruptible DOM updates, but we still lack a trigger.

Triggering Updates

In React, the common trigger is useState, the most basic update mechanism. Let's implement it to ignite our Fiber engine.

Here is the specific implementation:

// Associate the hook with the fiber node.
function useState<S>(initState: S): [S, (value: S) => void] {
  const fiberNode: FiberNode<S> = wipFiber;
  const hook: {
    state: S;
    queue: S[];
  } = fiberNode?.alternate?.hooks
    ? fiberNode.alternate.hooks[hookIndex]
    : {
        state: initState,
        queue: [],
      };

  while (hook.queue.length) {
    let newState = hook.queue.shift();
    if (isPlainObject(hook.state) && isPlainObject(newState)) {
      newState = { ...hook.state, ...newState };
    }
    if (isDef(newState)) {
      hook.state = newState;
    }
  }

  if (typeof fiberNode.hooks === 'undefined') {
    fiberNode.hooks = [];
  }

  fiberNode.hooks.push(hook);
  hookIndex += 1;

  const setState = (value: S) => {
    hook.queue.push(value);
    if (currentRoot) {
      wipRoot = {
        type: currentRoot.type,
        dom: currentRoot.dom,
        props: currentRoot.props,
        alternate: currentRoot,
      };
      nextUnitOfWork = wipRoot;
      deletions = [];
      currentRoot = null;
    }
  };

  return [hook.state, setState];
}

It cleverly keeps the hook's state on the fiber node and modifies the state through a queue. From here, you can also see why the order of React hook calls must not change.

Conclusion

We have implemented a minimal model of React that supports asynchronous and interruptible updates, with no dependencies, and excluding comments and types, it might be less than 400 lines of code. I hope it helps you.

If you find my content helpful, please consider subscribing. I send a weekly newsletter every Sunday with the latest web development updates. Thanks for your support!

Join the conversation

or to participate.