Understanding React by Implementing It

August 12, 2021

Recently I read Blogged Answers: A (Mostly) Complete Guide to React Rendering Behavior by Mark Erikson (@acemarke) and it inspired me to an experiment.

I realized that although React has currently very intimidating internals, with the fiber architecture and concurrent mode (scheduler, lanes...), it is based on just a few core principles.

In this post, I will do a walkthrough of implementing React from scratch.

Final goal

The goal of this project is to achieve a working implementation of basic React features. I don't care about matching the details of implementation (and nuances that result from them) but I want to have the exact same API that will allow me to write hooks in functional components and have state updates and effects. Like this:

import { createElement as c, useState, useEffect } from "./lib";

function App() {
  const [articles, setArticles] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    getApiArticlesList.then((data) => {
      setLoading(false);
      setArticles(data);
    });
  }, []);

  if (loading) {
    return c("span", {}, "Loading...");
  }

  return c(
    "div",
    {},
    articles.map((article) => c("span", {}, article.title)),
  );
}

render(c(App, {}, []), document.getElementById("root"));

The foundations of React

One way to build a mental model of what is happening is that we render HTML and sprinkle some JavaScript around it.

<div>
  <h1>Hello there</h1>
  <div class="article">Some content in here.</div>
</div>

The above is what pure HTML would look like. Now we can add some JavaScript in the form of components:

function Title({ children }) {
  return <h1>{children}</h1>;
}

<div>
  <Title>Hello there</Title>
  <div class="article">Some content in here.</div>
</div>;

Actually, we want to structure our app purely using components. And function responsible for taking it to the screen will be called render.

function Title({ children }) {
  return <h1>{children}</h1>;
}

function App() {
  return (
    <div>
      <Title>Hello there</Title>
      <div class="article">Some content in here.</div>
    </div>
  );
}

render(<App />, document.getElementById("root"));

This is pretty much already React code above. And now we can unwrap the JSX magic to see what React 'sees' when rendering the code. As we can read in the official React docs guide – JSX In Depth, JSX is actually resolved as calls of createElement() function.

function Title({ children }) {
  return createElement("h1", {}, [children]);
}

function App() {
  return createElement(
    "div",
    {},
    createElement(Title, {}, ["Hello there"]),
    createElement("div", { class: "article" }, ["Some content in here."]),
  );
}

render(createElement(App, {}, []), document.getElementById("root"));
NOTE

it might be a good moment to open your code editor if you are planning to follow along. For a quick start, I recommend yarn create vite and then picking vanilla template and vanilla-ts variant. Then simply copy and paste all the code (or try typing it yourself if you wish).

For simplicity let's assume that the type declaration of this function is:

type Props = {
  // children array is a special prop that we want to have predefined in 
  // case it appears. 
  children?: ReactElement[];
  [key: string]: any;
};

function createElement(
  // What is rendered in the component. If it is a DOM node - it should be a 
  // string. If component - it should be a function. 
  component: ((props: Props) => ReactElement) | string,
  // Initial typechecking will be very poor - providing an object is enough 
  // to satisfy us. 
  props: Props,
  // Again, for simplicity, children is an array of elements, even if it is 
  // a single child (or none). 
  children: ReactElement[],
) {
  // TODO: we will implement it later.
}

What is the ReactElement? A definition of intent to render. It doesn't represent any actual component instance yet, it's simply an object with instructions for what to do.

We will define 3 kinds of nodes. COMPONENT will be for the functional components, HOST nodes are HTML elements with corresponding DOM nodes and TEXT is the text node.

For example <div>test</div> contains a text node with content 'test'.

<div><h1>abc</h1>def</div> is a div with two children: h1 host node and text node 'def'.

enum NodeType {
  COMPONENT,
  HOST,
  TEXT,
}

type ComponentElement = {
  kind: NodeType.COMPONENT;
  component: (props: Props) => ReactElement;
  props: Props;
};

type HostElement = {
  kind: NodeType.HOST;
  tag: string;
  props: Props;
  // For now we don't add a DOM node reference. Remember, the element is 
  // only _intent_ of rendering, not actual alive present in your 
  // application. 
};

type TextElement = {
  kind: NodeType.TEXT;
  content: string;
};

type ReactElement = ComponentElement | HostElement | TextElement;

Rendering

An important fact to realize is that until we add state, the only moment in which our code is rendered to the screen is inside the render() function call. I don't know how React does it precisely, but I know what it could do to achieve the result.

Here are the types of nodes that I will use:

type BaseNode = {
  parent: ReactNode | null;
  // I am calling it 'descendants' to avoid confusion with 'children' prop.
  descendants: ReactNode[];
};

type ComponentNode = ComponentElement & BaseNode;
type HostNode = HostElement & BaseNode & { dom: any };
type TextNode = TextElement & { parent: ReactNode; dom: any };

type ReactNode = ComponentNode | HostNode | TextNode;

And the actual render() function.

The update is a separate function that can start work from any point (with useState in mind).

The first parameter, node, is the starting point in the tree where we will be doing our replacements. For main render() function we will generate a root node, for useState updates we will start processing from the component that had a state update.

The second parameter, element, is the plan for changes we will make. So in the case of useState there is no specific plan, it will be figured out once we rerender the component. That's why we accept null as the value here.

We have to create some kind of root node for the tree. In this case we will base on the fact that user while calling render() function, provides us with a host node container so we will construct the root node based on it.

function render(element: ReactElement, dom: HTMLElement) {
  const rootNode: HostNode = {
    kind: NodeType.HOST,
    tag: dom.tagName.toLowerCase(),
    props: {
      children: [element],
    },
    parent: null,
    descendants: [],
    dom,
  };

  update(rootNode, createElement(rootNode.tag, rootNode.props, [element]));
}

The update function will quickly grow and be one of the biggest (and definitely the most important) functions in this implementation.

function update(node: ReactNode, element: ReactElement | null) {
  let elements: ReactElement[] = [];

  // First we find out what to work on. For component, we have to call 
  // render function to find out what React elements it will return this 
  // time. 
  if (node.kind === NodeType.COMPONENT) {
    elements = [node.component(node.props)];
  } else if (element && "props" in element && node.kind === NodeType.HOST) {
    elements = element.props.children || [];
  } else if (
    (element && element.kind === NodeType.TEXT) ||
    node.kind === NodeType.TEXT
  ) {
    // Operations for the text node were done in the parent node.
    return;
  }

  for (const expected of elements) {
    let newNode: ReactNode;

    // It happens if we are removing the component. We will handle it later.
    if (expected === undefined) {
      return;
    }

    if (expected.kind === NodeType.COMPONENT) {
      newNode = {
        ...expected,
        parent: node,
        descendants: [],
      };
    } else if (expected.kind === NodeType.HOST) {
      const nodeConstruction: any = {
        ...expected,
        parent: node,
        descendants: [],
      };

      // Here we create a new DOM node. Note that we should also apply HTML 
      // attributes coming from the props, but we can do this later. 
      const dom = document.createElement(expected.tag);
      const hostNodeParent = findClosestHostNode(node);
      hostNodeParent.dom.appendChild(dom);

      nodeConstruction.dom = dom;
      newNode = nodeConstruction;
    } else if (expected.kind === NodeType.TEXT) {
      const nodeConstruction: any = {
        ...expected,
        parent: node,
      };

      const dom = document.createTextNode(expected.content);

      const hostNodeParent = findClosestHostNode(node);
      hostNodeParent.dom.appendChild(dom);

      nodeConstruction.dom = dom;
      newNode = nodeConstruction;
    } else {
      // In order to avoid error:
      // Variable 'newNode' is used before being assigned. ts(2454)
      throw new Error("Unknown node type");
    }

    node.descendants.push(newNode);
    update(newNode, expected);
  }
}

Missing findClosestNode implementation:

function findClosestHostNode(node: ReactNode): HostNode {
  let current = node;

  while (current.kind !== NodeType.HOST && current.parent) {
    current = current.parent;
  }

  // We are only interested in looking for the host node as the text node 
  // wouldn't have children anyway. 
  if (current.kind !== NodeType.HOST) {
    throw new Error("Couldn't find node.");
  }

  return current;
}

And the one for createElement():

function createElement(
  component: ((props: Props) => ReactElement) | string,
  props: Props,
  // Update: to allow c('span', {}, ['Text']), children must have the option 
  // to be a string. 
  children: (ReactElement | string)[],
): ReactElement {
  const p = {
    ...props,
    children: children.map((child) => {
      // Notice what happens here: we allow strings to be part of children 
      // array, but, for simplicity of the implementation, we immediately 
      // swap them back to ReactElement. 
      if (typeof child === "string") {
        return {
          kind: NodeType.TEXT,
          content: child,
        } as TextElement;
      } else {
        return child;
      }
    }),
  };

  if (typeof component === "string") {
    return {
      kind: NodeType.HOST,
      tag: component,
      props: p,
    };
  } else if (typeof component === "function") {
    return {
      kind: NodeType.COMPONENT,
      component,
      props: p,
    };
  } else {
    throw new Error("Wrong createElement parameters.");
  }
}

Finally, let's try rendering:

const c = createElement;

function Title({ children }: Props) {
  return c("h1", {}, children || []);
}

function App() {
  return c("div", {}, [c(Title, {}, ["Test"]), c("span", {}, ["Hello"])]);
}

render(c(App, {}, []), document.getElementById("app"));

If everything went right, you will see a very basic website in the browser. That's an enormous success, we have our own React now!

Example rendered below:

Props and events

One thing we are definitely missing is assigning props. As a bonus, we will already handle another important feature which is event handling. This will allow us to write <button onClick={() => console.log('Click')}>Click me</button> and have our React take care of adding and removing the browser event.

Our DOM creation logic expands to become this:

// Element type comes from browser HTML element type.
function createDom(element: HostElement): Element {
  const html = document.createElement(element.tag);

  Object.entries(element.props).forEach(([key, value]) => {
    if (key === "children" || key === "ref") {
      // Skip.
    } else if (isEvent(key)) {
      // We want to translate 'onClick' to 'click'.
      html.addEventListener(eventToKeyword(key), value);
    } else {
      html.setAttribute(camelCaseToKebab(key), value);
    }
  });

  return html;
}

One replacement in the current code we have:

- const dom = document.createElement(expected.tag);
+ const dom = createDom(expected);

And now the mysterious helpers declared above:

// We assume something is an event when it follows onSomething naming 
// pattern. 
const isEvent = (key: string): boolean => !!key.match(new RegExp("on[A-Z].*"));

// Event is transformed to browser event name by removing 'on' and 
// lowercasing. 'onInput' becomes 'input'. 
function eventToKeyword(key: string): string {
  return key.replace("on", "").toLowerCase();
}

// camelCaseWillBecome kebab-case-with-dashes.
function camelCaseToKebab(str: string) {
  return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
}

When we use this as App() function we have a console-logging button!

function App() {
  const onClick = () => {
    console.log("Click");
  };

  return c("div", {}, [c("button", { onClick }, ["Click"])]);
}

useState

Our React can statically render some content but we are missing one extremely important core feature - state. As I already skipped adding class-based components (imagine how complex the code would already get if we wanted to support those), we need to implement only useState hook.

To avoid future mess, let's start with a solid type foundation:

enum HookType {
  STATE,
  // Soon EFFECT, CONTEXT, MEMO, REF might join.
}

type StateHook = {
  type: HookType.STATE;
  state: any;
};

type Hook = StateHook; // | EffectHook | RefHook...

Using the magic of closures

When I first learned about React hooks, it was very hard for me to imagine how hooks and especially useState might work. Because hooks rely on the order of calls, let's start with declaring an array of them.

type ComponentNode = ComponentElement & BaseNode & { hooks: Hook[] };
//                                              ^^^^^^^^^^^^^^^^^^^^^
//                                              ADD THIS PART

And in the part for adding components:

if (expected.kind === NodeType.COMPONENT) {
  newNode = {
    ...expected,
    parent: node,
    descendants: [],
    hooks: [],
//  ^^^^^^^^^^
  };

Since useState is, at first glance, a magic function that can be called in any component and it always 'knows' what is the state associated with that particular call, we need to somehow implement this magic.

We will keep track of the currently processed node and the hook index.

This is where the famous behaviour of React – hooks will automagically work fine, as long as you declare them always in the same order, comes to play. Because it is basically an array.

Add this somewhere in the global scope:

let _currentNode: ReactNode | null;
let _hookIndex = 0;

We need to add the following declaration to the top of update() function.

const previousNode = _currentNode;
const previousIndex = _hookIndex;
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

let elements: ReactElement[] = [];

if (node.kind === NodeType.COMPONENT) {
  _currentNode = node;
  _hookIndex = 0;
  // ^^^^^^^^^^^^
  // THOSE TWO LINES TOO
  elements = [node.component(node.props)];
  // ...

And matching closing in the end of the function:

_currentNode = previousNode;
_hookIndex = previousIndex;

Note that call to the useState will happen inside node.component(node.props) call, so we will have information about the component being currently rendered.

The hook itself

Now the actual useState hook.

Comment about the API here: setState can be called with either a new value or a function that takes the current (unknown in outer context) value and provides instructions for calculating the new one.

So it is used either as setState(counter + 10) or setState(counter => counter + 10). The second variant comes in handy when we can't rely on the counter coming from the local scope.

function useState<T>(initial: T): [T, (next: T | ((current: T) => T)) => void] {
  // Capture the current node, so in the setState callback we will use the 
  // node it was created for, not whatever will be the value of this 
  // variable in future. 
  const c = _currentNode;
  const i = _hookIndex;

  if (!c || c.kind !== NodeType.COMPONENT) {
    throw new Error("Executing useState for non-function element.");
  }

  if (c.hooks[i] === undefined) {
    c.hooks[i] = {
      type: HookType.STATE,
      state: initial,
    };
  }

  const hook = c.hooks[i];
  // Just for type correctness but it's also a good place to spot potential 
  // implementation bugs. 
  if (hook.type !== HookType.STATE) {
    throw new Error("Something went wrong.");
  }

  const setState = (next: T | ((current: T) => T)) => {
    // 
    // https://github.com/microsoft/TypeScript/issues/37663#issuecomment-856866935 
    // In case of a different iframe, window or realm, next won't be an 
    // instance of the same Function and will be saved instead of treated as 
    // callback. 
    if (next instanceof Function) {
      hook.state = next(hook.state);
    } else {
      hook.state = next;
    }

    // Here we don't provide any new plan for the component. It will come 
    // from the component callback call. 
    update(c, null);
  };

  _hookIndex += 1;
  return [hook.state, setState];
}

Adjusting update loop to support other node operations

For now, the update loop handles just one operation – node addition. To support the state it needs a few more variants. If we are adding nodes we also want to remove them. If we have a component that rerenders but stays the same React node – we want to update it. Finally, sometimes in place of a component, we have another node. We could remove it and add a new one, but maybe there's some optimization potential in replacing them.

Our current loop for handling children looks like this:

for (const expected of elements) {

Now we need to go from our limited one-way rendering implementation into fully functioning React, which compares existing components (the famous Virtual DOM) and updates them according to the result of functional components.

This part is one of the core optimizations in React. In general, the comparison of trees has algorithmic complexity O(n^3). But as people behind React found, we can achieve it in O(n). The first observation to make is that our tree doesn't jump around too much. We render the component and its children we either change or not. This leads to the conclusion that we can actually compare currently existing nodes to the expected React elements and make an update based on that.

So the solution is: we do something similar to the popular zip array operation and we join nodes that are children of node with expected elements that are either result of render() function of a component or children of a host element.

Replace the above for (const expected of... loop with this:

// We want to have the outer join of the two lists so we can decide on 
// adding or removing components as well. 
const length = Math.max(node.descendants.length, elements.length);
const pairs: [left: ReactNode | undefined, right: ReactElement | undefined][] =
  [];

for (let i = 0; i < length; i++) {
  pairs.push([node.descendants[i], elements[i]]);
}

// For each pair we decide what operation (add, update, replace, remove) to 
// make. 
for (const [current, expected] of pairs) {
  const shouldUpdate =
    // Both are defined and...
    current &&
    expected &&
    //...it was and still is component and previous and current functional 
    // component function is the same one (which means we are rerendering) 
    // or... 
    ((current.kind === NodeType.COMPONENT &&
      expected.kind === NodeType.COMPONENT &&
      current.component === expected.component) ||
      // ... it was and still is host node and tag has not changed (which 
      // means we just need to update props) or... 
      (current.kind === NodeType.HOST &&
        expected.kind === NodeType.HOST &&
        current.tag === expected.tag) ||
      // ...it was and is text node (hich means we update text content).
      (current.kind === NodeType.TEXT && expected.kind === NodeType.TEXT));
  const shouldReplace = current && expected;
  const shouldAdd = !current && expected !== undefined;
  const shouldRemove = current !== undefined && !expected;

  if (shouldUpdate) {
    updateNode(current!, expected!);
  } else if (shouldReplace) {
    replaceNode(node, current!, expected!);
  } else if (shouldAdd) {
    addNode(node, expected!);
  } else if (shouldRemove) {
    removeNode(node, current!);
  }
}

Adding nodes

This is mostly the old code we used, moved to a separate function.

function addNode(node: ComponentNode | HostNode, expected: ReactElement) {
  let newNode: ReactNode;

  if (expected.kind === NodeType.COMPONENT) {
    newNode = {
      ...expected,
      parent: node,
      descendants: [],
      hooks: [],
    };
  } else if (expected.kind === NodeType.HOST) {
    const nodeConstruction: any = {
      ...expected,
      parent: node,
      descendants: [],
    };

    // Here we create new DOM node. Note that we should also apply
    const dom = createDom(expected);

    // This is a problem that is not obvious to spot at first glance. 
    // Because our node tree consists of either component, host and text 
    // nodes, it's not possible to just attach DOM node to the parent 
    // because parent is not necessarily a host node. The solution is not 
    // complicated though, we can recursively look for a parent that is a 
    // host node (will implement later). 
    const hostNodeParent = findClosestHostNode(node);
    hostNodeParent.dom.appendChild(dom);

    nodeConstruction.dom = dom;
    newNode = nodeConstruction;
  } else if (expected.kind === NodeType.TEXT) {
    const nodeConstruction: any = {
      ...expected,
      parent: node,
      descendants: [],
    };

    const dom = createTextNode(expected.content);

    const hostNodeParent = findClosestHostNode(node);
    hostNodeParent.dom.appendChild(dom);

    nodeConstruction.dom = dom;
    newNode = nodeConstruction;
  } else {
    throw new Error("Unknown node type.");
  }

  node.descendants.push(newNode);
  update(newNode, expected);
}

A missing helper:

function createTextNode(text: string): Text {
  return document.createTextNode(text);
}

Updating nodes

function updateNode(current: ReactNode, expected: ReactElement) {
  if (current.kind === NodeType.HOST && expected.kind === NodeType.HOST) {
    updateDom(current, expected);
  } else if (
    // Text value changed.
    current.kind === NodeType.TEXT &&
    expected.kind === NodeType.TEXT &&
    current.content !== expected.content
  ) {
    current.content = expected.content;
    updateTextNode(current, expected.content);
  }

  // Props can be replaced.
  if ("props" in current && "props" in expected) {
    current.props = expected.props;
  }

  update(current, expected);
}

A helper:

function updateTextNode(current: TextNode, text: string) {
  current.dom.nodeValue = text;
}

Updating DOM nodes is a bit trickier than just adding them.

function updateDom(current: HostNode, expected: HostElement) {
  const html = current.dom as HTMLElement;

  // First we iterate over current props, which means we might either update 
  // them or decide to remove them. 
  Object.keys(current.props).forEach((key) => {
    if (key === "children" || key === "ref") {
      // Skip.
    } else if (isEvent(key)) {
      html.removeEventListener(eventToKeyword(key), current.props[key]);
    } else {
      // Prop will be removed.
      if (!expected.props[key]) {
        html.removeAttribute(key);
      }

      // Prop will be updated.
      if (expected.props[key]) {
        html.setAttribute(camelCaseToKebab(key), expected.props[key] as string);
      }
    }
  });

  // Now, iterating over new props we will overwrite the ones we have in 
  // expected object. 
  Object.keys(expected.props).forEach((key) => {
    if (key === "children" || key === "ref") {
      // Skip.
    } else if (isEvent(key)) {
      html.addEventListener(eventToKeyword(key), expected.props[key]);
    } else {
      // Prop will be added.
      if (!current.props[key]) {
        html.setAttribute(camelCaseToKebab(key), expected.props[key] as string);
      }
    }
  });
}

Replacing nodes

Replacing shares characteristics of both addition and deletion.

function replaceNode(
  node: ComponentNode | HostNode,
  current: ReactNode,
  expected: ReactElement,
) {
  let newNode: ReactNode;
  if (expected.kind === NodeType.COMPONENT) {
    newNode = {
      ...expected,
      parent: node,
      descendants: [],
      hooks: [],
    };

    removeDom(current);
  } else if (expected.kind === NodeType.HOST) {
    const firstParentWithHostNode = findClosestHostNode(node);

    const nodeConstruction: any = {
      ...expected,
      parent: node,
      descendants: [],
    };

    const dom = createDom(expected);
    if (current.kind === NodeType.HOST || current.kind === NodeType.TEXT) {
      firstParentWithHostNode.dom.replaceChild(dom, current.dom);
    } else {
      removeDom(current);
      firstParentWithHostNode.dom.appendChild(dom);
    }
    nodeConstruction.dom = dom;

    newNode = nodeConstruction;
  } else if (expected.kind === NodeType.TEXT) {
    const firstParentWithHostNode = findClosestHostNode(node);
    const nodeConstruction: any = {
      ...expected,
      parent: node,
    };

    const dom = createTextNode(expected.content);
    if (current.kind === NodeType.TEXT) {
      throw new Error("Update should have happened on this node.");
    } else if (current.kind === NodeType.HOST) {
      firstParentWithHostNode.dom.replaceChild(dom, current.dom);
      nodeConstruction.dom = dom;
    } else {
      removeDom(current);
      firstParentWithHostNode.dom.appendChild(dom);
      nodeConstruction.dom = dom;
    }

    newNode = nodeConstruction;
  } else {
    throw new Error("Couldn't resolve node kind.");
  }

  node.descendants[node.descendants.indexOf(current)] = newNode;
  update(newNode, expected);
}

Helper:

function removeDom(node: ReactNode) {
  if (node.kind === NodeType.HOST || node.kind === NodeType.TEXT) {
    node.dom.parentNode?.removeChild(node.dom);
  } else {
    node.descendants.forEach((child) => {
      removeDom(child);
    });
  }
}

Removing nodes

Operation of removing node is much less complex compared to others.

function removeNode(node: ComponentNode | HostNode, current: ReactNode) {
  const indexOfCurrent = node.descendants.indexOf(current);
  removeDom(current);
  node.descendants.splice(indexOfCurrent, 1);
}

Example

With all the code we just added we are ready to run some actual app code again. We can replace our previous App with this:

function Counter() {
  const [counter, setCounter] = useState(0);
  const onClick = () => setCounter(counter + 1);
  return c("button", { onClick }, [`${counter}`]);
}

render(c(Counter, {}, []), document.getElementById("app"));

And if everything went right we have a button with a counter that increases when we click it. Amazing!

useEffect

Another important piece without which our React won't be complete. We need some ability to run effects in response to changes in props and other values.

function useEffect(
  callback: () => void | (() => void),
  dependencies?: any[],
): void {
  // Capture the current node.
  const c = _currentNode;
  const i = _hookIndex;

  if (!c || c.kind !== NodeType.COMPONENT) {
    throw new Error("Executing useEffect for non-function element.");
  }

  if (c.hooks[i] === undefined) {
    // INITIALIZE
    const hook: EffectHook = {
      type: HookType.EFFECT,
      cleanup: undefined,
      dependencies,
    };
    c.hooks[i] = hook;
    const cleanup = callback();
    hook.cleanup = cleanup ? cleanup : undefined;
  } else if (dependencies) {
    // COMPARE DEPENDENCIES
    const hook = c.hooks[i];
    if (hook.type !== HookType.EFFECT || hook.dependencies === undefined) {
      throw new Error("Something went wrong.");
    }

    let shouldRun = false;
    for (let j = 0; j < dependencies.length; j++) {
      if (dependencies[j] !== hook.dependencies[j]) {
        shouldRun = true;
      }
    }

    if (shouldRun) {
      const cleanup = callback();
      c.hooks[i] = {
        type: HookType.EFFECT,
        cleanup: cleanup ? cleanup : undefined,
        dependencies,
      };
    }
  } else if (!dependencies) {
    // RUN ALWAYS
    const cleanup = callback();
    c.hooks[i] = {
      type: HookType.EFFECT,
      cleanup: cleanup ? cleanup : undefined,
      dependencies,
    };
  }

  _hookIndex += 1;
}
enum HookType {
  STATE,
  EFFECT,
}

type EffectHook = {
  type: HookType.EFFECT;
  cleanup?: () => void;
  dependencies?: any[];
};

type StateHook = {
  // No changes.
};

type Hook = StateHook | EffectHook;

Fixing implementation

There's one problem with our implementation that will become visible once we start building more complex apps – all hooks run immediately. In many situations, this issue will not be visible. If we do state update inside a click event, setTimeout or in response to a promise being resolved, the change will happen after the rendering loop finishes. But, we will suffer if we will set the state directly in a useEffect.

Have a look at this problem:

function Articles() {
  const [a, setA] = useState("a");

  // Check the useEffect implementation: we enter this callback immediately.
  useEffect(() => {
    // We enter this callback right now. It renders "1" to the screen.
    setA("1");
  }, []);

  // Here we go again.
  useEffect(() => {
    // We render "A".
    setA("A");
  }, []);

  // Now we render "a" to the screen.
  return c("div", {}, [a]);
}

// We see "a" on the screen while we expect to see "A".

The idea of the fix is based on queueing – we finish the current loop and then start a new one if there is something to do that appears in the meantime.

type Job = {
  node: ReactNode;
  element: ReactElement | null;
};

const _tasks: Job[] = [];
const _effects: (() => void)[] = [];
let _updating = false;

function runUpdateLoop(node: ReactNode, element: ReactElement | null) {
  _tasks.push({ node, element });

  if (_updating) {
    return;
  }

  _updating = true;

  let current: Job | undefined;
  while ((current = _tasks.shift())) {
    update(current.node, current.element);

    // Run all effects queued for this update.
    let effect: (() => void) | undefined;
    while ((effect = _effects.shift())) {
      effect();
    }
  }

  _updating = false;
}
// Wrap the main useEffect action in a callback that we push to the effects 
// array: 
_effects.push(() => {
  if (c.hooks[i] === undefined) {
    // INITIALIZE
    const hook: EffectHook = {};
    // ...
  }
});

_hookIndex += 1; // This stays outside.
NOTE

Replace all update(...) calls with runUpdateLoop() (only the one inside this function stays the same).

Proof that it worked:

JSX

Time to make our product not only work like React, but also look like React.

First, rename your file containing JSX to have extension *.tsx and update link in the index.html to the new file name.

Then we will need some adjustments to the createElement() function. We started with a simplified version that accepted just arrays of elements or strings. React is more complex in this regard and JSX can produce calls like:

Simple example

// Before
<div>test</div>

// After
c("div", null, "test"));

Variable number of arguments, numbers

// Before
<div>
  test
  {2}
</div>;

// After
c("div", {}, "test", 1);

Array as argument

// Before
<div>
  {[1, 2, 3].map((i) => (
    <div>{i}</div>
  ))}
</div>;

// After
c(
  "div",
  {},
  [1, 2, 3].map((i) => c("div", {}, i)),
);

One-element array

// Before
<div>{["test"]}</div>

// After
c("div", null, ["test"]));

False values

// Before
<div>{false && "test"}</div>

// After
c("div", null, false));

Adjustments

To handle those, we will change the createElement() function to take variable number of arguments and handle numbers, nulls and false values. Also props will be null if it's empty, instead of {}.

function createElement(
  component: any,
  props: any,
  ...children: (ReactElement | string | number | null)[]
): ReactElement {
  const p = {
    ...(props || {}),
    children: children
      .flat()
      .map((child: ReactElement | string | number | null) => {
        if (typeof child === "string") {
          return {
            kind: NodeType.TEXT,
            content: child,
          };
        } else if (typeof child === "number") {
          return {
            kind: NodeType.TEXT,
            content: child.toString(),
          };
        } else {
          // Null and false will be passed here and filtered below.
          return child;
        }
      })
      .filter(Boolean),
  };

  if (typeof component === "function") {
    return {
      kind: NodeType.COMPONENT,
      component,
      props: p,
    };
  } else if (typeof component === "string") {
    return {
      kind: NodeType.HOST,
      tag: component,
      props: p,
    };
  }

  throw new Error("Something went wrong.");
}

In the render function remove array around element:

runUpdateLoop(rootNode, createElement(rootNode.tag, rootNode.props, element));

Create a new file called jsx.ts:

declare namespace JSX {
  interface IntrinsicElements {
    [elementName: string]: any;
  }
}

Update tsconfig.json to add those two lines:

{
  "compilerOptions": {
    // ...
    "jsxFactory": "c",
    "jsxFragmentFactory": "Fragment"
  }
}

Fragment is not needed yet, but it is something that is very natural to implement later on so we might as well add it already.

We need to change config of Vite to tell esbuild how to process JSX. Create vite.config.js:

function jsx() {
  return {
    name: "jsx",
    config() {
      return {
        esbuild: {
          jsxFactory: "c",
          jsxFragment: "Fragment",
        },
      };
    },
  };
}

export default {
  plugins: [jsx()],
};

In this case, remember to keep const c = createElement somewhere in your 'client' code!

After restart of bundler and a few seconds for VSCode to realize what happened, we should be able to write JSX in our code.

function App() {
  return (
    <div>
      {[1, 2, 3].map((i) => (
        <div>{i}</div>
      ))}
      <div>
        test
        {2}
      </div>
      <div>{["test"]}</div>
      <div>test</div>
      <div>{false && "test"}</div>
    </div>
  );
}

render(<App />, document.getElementById("app"));

Conclusions

We did it. We have a small React implementation in 573 lines of code. Obviously this implementation is very limited, is lacking important security patches and generally should never be used in prod, but, it feels so good to achieve it ourselves and understand how it works.

Here is a Gist with full code.

Future improvements

Although we have a simplified React with state and effects, there's a long way before we can use it for rewriting real React apps (disclaimer: we should never ever do it and just keep relying on extremely battle tested official React, but, what if...). We are missing several important features:

key prop

The basic (and very effective) algorithm for comparing current and expected nodes has one serious problem. Whenever we insert a new element to our children list that wasn't there before, we end up tearing apart everything starting from the added element until the end of the list. It is more common than it might seem, as we bump into this when either by returning something from conditional render that was null or false before, like loading && <Spinner />, or by adding (or reordering) something to a list of items (new data from API, drag & drop).

The solution to this is the key prop, which informs React which element from current render should map to which element of the expected elements.

useRef

Attaching DOM events, manipulating nodes. We are definitely missing out without it.

Fragments

Solution to a long lasting React problem – cluttering DOM with absolutely unnecesary <div>s that don't reflect the logical structure of our map but are leaked implementation detail of our component structure.

Context API

Among many things, a solution to props drilling.

SSR

Render HTML server side, send to the client and 'hydrate' it with React by attaching all button click handlers etc.

Fast refresh

Enormous advantage for developer experience.

Fiber architecture

What beasts are hiding there?

Concurrent mode

Entirely new approach for stopping and resuming rendering.

Full library

Before finishing this article, I added a bit more features to my toy library – useRef, context API, <Fragment />s, SSR and even hot module reloading (known nowadays more as 'Fast refresh'). Check out the repository for a lot more inspiration if you are feeling hungry after this post!

Cool resources

<-
Homepage

Stay up to date with a newsletter

Sometimes I write blogposts. It doesn’t happen very often or in regular intervals, so subscribing to my newsletter might come in handy if you enjoy what I am writing about.

Never any spam, unsubscribe at any time.