Understanding React by implementing it

From the creator of broken Google Maps clone, now a broken React ready to conquer your node_modules.

Aug 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:

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

const 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 how pure HTML would look like. Now we can add some JavaScript in form of components:

const 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.

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

const 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.

const Title = ({ children }) => {
  return createElement("h1", {}, [children]);
};

const 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 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 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 kind of nodes. COMPONENT will be for the functional componenents, HOST nodes are HTML elements with corresponding DOM nodes and TEXT is the text node.

For example <div>test</div> contains 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, 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

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 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.

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

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.

Second parameter, element, is the plan for changes we will make. So in 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 host node as 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 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;

const Title = ({ children }: Props) => c("h1", {}, children || []);

const 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 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 event when it follow 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'.
const eventToKeyword = (key: string): string =>
  key.replace("on", "").toLowerCase();

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

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

const 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 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 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 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 currently rendering component in the global context.

The hook itself

Now the actual useState hook.

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

So it is used either as setState(counter + 10) or setState(counter => counter + 10). Second variant comes 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 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 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 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). First observation to make is that our tree doesn't jump around too much. We render component and its children we either change or not. This leads to a 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. 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:

const 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:

const 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.

const 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

Newsletter

Sometimes I write blogposts. If you want to get an old fashioned email announcing arrival of a new tech writing piece from me – you can leave your contact details below.

At the moment there are ... people subscribing.

<- Go to homepage
© Tomasz Czajęcki 2018 – 2022. All Rights Reserved.