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:
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.
ZacharyL2 / mini-react
Implement Mini-React in 400 lines of code, a minimal model with asynchronous interruptible updates.
Mini-React
Implement Mini-React in 400 lines of code, a minimal model with asynchronous interruptible updates.
Demos
A simple running screenshot:
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!
License
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.
You can see that it calls React.createElement
, which provides the following options:
- type: Indicates the type of the current node, such as
div
. - config: Represents the attributes of the current element node, for example,
{id: "test"}
. - 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.
// 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);
};
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.
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
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);
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:
In React, each work unit is called a Fiber node. They are linked together using a linked list-like structure:
- child: Pointer from the parent node to the first child element.
- return/parent: All child elements have a pointer back to the parent element.
- 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
.
-
workLoop
: Get idle time by callingrequestIdleCallback
continuously. If it is currently idle and there are unit tasks to be executed, then execute each unit task. -
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. -
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. -
commitRoot
: If an update is currently required (according towipRoot
) 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. ThecommitRoot
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];
}
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)
Excellent insights
Glad you found it useful
Welcome to the large community of framework-creators!
Great i too was planning to dig into react
That's awesome. Please add useEffect!