DEV Community

Cover image for Build Your Own React.js in 400 Lines of Code
Zachary Lee
Zachary Lee

Posted on • Edited on • Originally published at webdeveloper.beehiiv.com

Build Your Own React.js in 400 Lines of Code

Originally published in my newsletter.

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:

img

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.

GitHub logo ZacharyL2 / mini-react

Implement Mini-React in 400 lines of code, a minimal model with asynchronous interruptible updates.

Presentation

Mini-React

Implement Mini-React in 400 lines of code, a minimal model with asynchronous interruptible updates.

Demos

Online Demo

A simple running screenshot:

Demo

Introduce

I used the Tic-Tac-Toe tutorial example provided on the React website, and it works well.

Additionally, it supports both functional and class components. Its overall logic and function naming are largely consistent with React's fundamentals. If you are interested in the inner workings of React, then this tutorial is suitable for you!

See how to build it.

License

MIT




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.

img

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.

img

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,
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

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.

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

// Create custom JavaScript data structures.
const createElement = (type, props = {}, ...child) => {
  const isVirtualElement = (e) => typeof e === 'object';

  const children = child.map((c) =>
    isVirtualElement(c) ? c : createTextElement(String(c))
  );

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

// 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);
};
Enter fullscreen mode Exit fullscreen mode

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

Fiber Architecture & 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.

img

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.

[React Conf 2024](https://www.youtube.com/watch?v=T8TZQ6k4SLE&list=PLNG_1j3cPCaaY3NEriypd7FKyWqbBNuAB&index=2&utm_source=webdeveloper.beehiiv.com&utm_medium=referral&utm_campaign=build-your-own-react-js-in-400-lines-of-code)

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 API

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);
Enter fullscreen mode Exit fullscreen mode

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.

This is a primary reason why the React package is so large.

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

img

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 most common trigger is useState, the most basic update mechanism. Let's implement it to ignite our Fiber engine.

Here is the specific implementation, simplified into a function:

// 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];
}
Enter fullscreen mode Exit fullscreen mode

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 this helpful, please consider subscribing to my newsletter for more insights on web development. Thank you for reading!

Top comments (5)

Collapse
 
muhammadsaim profile image
Muhammad Saim

Excellent insights

Collapse
 
zacharylee profile image
Zachary Lee

Glad you found it useful

Collapse
 
efpage profile image
Eckehard

Welcome to the large community of framework-creators!

Collapse
 
abdddd profile image
Abdul

Great i too was planning to dig into react

Collapse
 
kaiphan profile image
Quý

That's awesome. Please add useEffect!