The source code for this blog is available on GitHub.

Blog.

How to Build a Mini React: From JSX Element to useState

Fiber

To understand what a fiber is, first we need to know about JSX elements. A JSX element is just a plain object, in which there is a type property and a props property. Here is an example:

const element = {
  type: 'h1',
  props: {
    children: 'Hello World',
  },
}

The type property shows what kind of HTML element this object stands for, and the props show its properties, including children, if that element can have children.

And to create a JSX element for an h1 element, if we were to follow the api in React, we'll need to call createElement like this:

const element = createElement('h1', null, 'Hello World')

To do that, we also need a createElement method. Let's write it out:

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) =>
        typeof child === 'object' ? child : createTextElement(child)
      ),
    },
  }
}

function createTextElement(text) {
  return {
    type: 'TEXT_ELEMENT',
    props: {
      nodeValue: text,
      children: [],
    },
  }
}

// const element = {
//   type: 'h1',
//   props: {
//     children: [
//       {
//         type: 'TEXT_ELEMENT',
//         props: {nodeValue: 'Hello World', children: []},
//       },
//     ],
//   },
// }
const element = createElement('h1', null, 'Hello World')

So what about fibers? Well, a fiber is the "extension" of JSX element and it has more properties on it, which looks like this:

const fiber = {
  // these two are the same as those in JSX elements
  type: 'h1',
  props: {children: 'Hello World'},
  // the actual dom node the current fiber represents
  dom: domNode,
  // the current fiber's parent, first child and first sibling in the fiber tree
  parent: parentFiber,
  child: childFiber,
  sibling: null,
  // a alternative, work-in-progress version of the current fiber
  alternative: currentFiber,
  // any additional work associated during reconciliation
  effectTag: PLACEMENT,
  // any hooks that need to be run
  hooks: [],
}

Say we are going to create a fiber for the root container dom node, it will look like this:

let wipRoot = null
const container = document.getElementById('root')
wipRoot = {
  type: 'n/a', // special type of root fiber, normally a string or function
  dom: container, // container root node
  props: {
    children: [element], // the JSX element we just created, not fiber, yet.
  },
  // links to others
  // alternative // pending fiber
  // child // first child
  // parent
  // sibling // first sibling
}

And if we want to render the element onto the container node, the render function may look like this:

function render(element, container) {
  // create dom node
  const dom =
    element.type === 'TEXT_ELEMENT'
      ? document.createTextNode()
      : document.createElement(element.type)

  // check and add properties
  const isProperty = (key) => key !== 'children'
  Object.keys(element.props)
    .filter(isProperty)
    .forEach((name) => (dom[name] = element.props[name]))

  // recursively render children
  element.props.children.forEach((child) => render(child, dom))

  // mount
  container.appendChild(dom)
}

Reconciliation & Commit

As you can see, this rendering approach is top-to-bottom, recursive, all synchronous and will block the main execution thread. That means, during this kind of rendering process of React, if an user were to type something in an input field, or the browser were to render some animation, they will get jammed by React, which is horrible in perspective of performance and user experience. And that's why the React team came up with a better approach to React rendering, reconciliation and commit.

So the basic idea of reconciliation and commit is that React will split the whole rendering process into two parts. The first part, reconciliation, is the comparing process in which React calculate what's different between user interactions, and the second part, commit, is when React mount the calculation result of reconciliation onto the actual DOM. And to not blocking any necessary operations by the user and the browser, React will chop the whole reconciliation process into minimal units of work, which allows browser to ask React to pause the calculation and wait until the necessary user interactions or animation has been done and carry on. And those minimal units of work is represented by the fiber data structure. But why it won't chop the commit phase into work of units? That's because the mounting must not be paused, otherwise the data could be changed, which could sabotage the consistency between the data and the UI it populates.

The work loop for reconciliation is a depth-first iteration over the fiber tree. Starting from the wipRoot fiber, first traverse to the parent, then the first child, and if the child has its own child, go to that child. And if that child of the child, aka the grandchild of the parent has no children, and last it's the first sibling of the grandchild and the sibling's child. After all the reconciliation at the bottom is finished, we go all the way back up to the wipRoot fiber. The big O of this traverse is O(n) because this approach basically treats the tree like a list and traverse the nodes one by one.

Here is how the reconciliation and commit code looks like in concept:

let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
let deletions = []
let wipFiber = null
let hookIndex = null

nextUnitOfWork = wipRoot
while (nextUnitOfWork) {
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}
commitWork(wipRoot.child)

function performUnitOfWork(fiber) {
  const isFunctionComponent = fiber.type instanceof Function

  // if the fiber is a function component, call it
  if (isFunctionComponent) {
    wipFiber = fiber
    hookIndex = 0
    wipFiber.hooks = []
    const children = [fiber.type(fiber.props)]
    reconcileChildren(fiber, children.flat())
  } else {
    // otherwise just create a dom node for it
    if (!fiber.dom) fiber.dom = createDom(fiber)
    reconcileChildren(fiber, fiber.props.children.flat())
  }

  if (fiber.child) return fiber.child
  let nextFiber = fiber

  while (nextFiber) {
    if (nextFiber.sibling) return nextFiber.sibling
    nextFiber = nextFiber.parent
  }
}

function commitWork(fiber) {
  if (!fiber) return

  let domParentFiber = fiber.parent
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent
  }
  const domParent = domParentFiber.dom

  if (fiber.effectTag === 'PLACEMENT' && fiber.dom !== null) {
    domParent.appendChild(fiber.dom)
  } else if (fiber.effectTag === 'UPDATE' && fiber.dom !== null) {
    updateDom(fiber.dom, fiber.alternative.props, fiber.props)
  } else if (fiber.effectTag === 'DELETE') {
    commitDeletion(fiber, domParent)
  }

  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

export function reconcileChildren(wipFiber, elements) {
  let index = 0
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child
  let prevSibling = null
  while (index < elements.length || oldFiber != null) {
    const element = elements[index]
    let newFiber = null
    const sameType =
      oldFiber && element && element.type == oldFiber.type
    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: 'UPDATE',
      }
    }
    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: 'PLACEMENT',
      }
    }
    if (oldFiber && !sameType) {
      oldFiber.effectTag = 'DELETION'
      // deletions.push(oldFiber)
    }
    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }
    if (index === 0) {
      wipFiber.child = newFiber
    } else if (element) {
      prevSibling.sibling = newFiber
    }
    prevSibling = newFiber
    index++
  }
}

export function createDom(fiber) {
  const dom =
    fiber.type === 'TEXT_ELEMENT'
      ? document.createTextNode('')
      : document.createElement(fiber.type)
  updateDom(dom, {}, fiber.props)
  return dom
}

export function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom)
  } else {
    commitDeletion(fiber.child, domParent)
  }
}
export function updateDom(dom, prevProps, nextProps) {
  const isGone = (prev, next) => (key) => !(key in next)
  const isNew = (prev, next) => (key) => prev[key] !== next[key]
  const isEvent = (key) => key.startsWith('on')
  const isProperty = (key) => key !== 'children' && !isEvent(key)
  //Remove old or changed event listeners
  Object.keys(prevProps)
    .filter(isEvent)
    .filter(
      (key) => !(key in nextProps) || isNew(prevProps, nextProps)(key)
    )
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2)
      dom.removeEventListener(eventType, prevProps[name])
    })
  // Remove old properties
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach((name) => {
      dom[name] = ''
    })
  // Set new or changed properties
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      dom[name] = nextProps[name]
    })
  // Add event listeners
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2)
      dom.addEventListener(eventType, nextProps[name])
    })
}

In the real-world implementation of React, instead of just using a while loop, they use web APIs such as requestIdleCallback and requestAnimationFrame to keep the work loop running and pause when necessary.

// nextUnitOfWork = wipRoot
// while (nextUnitOfWork) {
//   nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
// }
// commitWork(wipRoot.child)

function workLoop(deadline) {
  // reconciliation
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
    // let browser pause it based on how much time is left
    shouldYield = deadline.timeRemaining() < 1
  }
  // commit
  if (!nextUnitOfWork && wipRoot) {
    deletions.forEach(commitWork)
    commitWork(wipRoot.child)
    currentRoot = wipRoot
    wipRoot = null
  }
  requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)

This will kickoff the render process, but we as React users don't usually write requestIdleCallback to render our apps. So let's write a proper render method.

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {children: [element]},
    alternative: currentRoot,
  }
  deletions = []
  nextUnitOfWork = wipRoot
}
render(element, container)

In the new concurrent mode implementation, a new API called createRoot was introduced, which looks like this:

function createRoot(container) {
  return {
    render(element) {
      wipRoot = {
        dom: container,
        props: {children: [element]},
        alternative: currentRoot,
      }
      deletions = []
      nextUnitOfWork = wipRoot
    },
  }
}
createRoot(container).render(element)

This makes it easier to render components when you have multiple roots.

useState

And here is what useState will look like in concepts.

function useState(initialState) {
  const oldHook = wipFiber?.alternative?.hooks[hookIndex]
  const hook = {
    state: oldHook ? oldHook.state : initialState,
    pendingState: '__NONE__',
  }
  if (oldHook && oldHook.pendingState !== '__NONE__') {
    hook.state = oldHook.pendingState
  }

  // for now only takes functions
  const setState = (setStateCallback) => {
    hook.pendingState = setStateCallback
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternative: currentRoot,
    }
    nextUnitOfWork = wipRoot
    deletions = []
  }
  wipFiber.hooks.push(hook)
  hookIndex++
  return [hook.state, setState]
}

It pulls out the pendingState, if any, from the old hook and render it, and also push the current state into the pending queue in the setState, clones the currentRoot fiber into the wipRoot and queues it to be the nextUnitOfWork. So the order these hooks are called here is important. You don't want to call a hook within a loop, an if statement or a callback to mess everything up.