Build a Tiny React Ch04 Hooks

fingerbone

Zend

Posted on October 20, 2024

Build a Tiny React Ch04 Hooks

Prev Part

This tutorial is based on this tutorial, but with JSX, typescript and an easier approach to implement. You can checkout the notes and code on my GitHub repo.

Okay, before diving into hooks, we need a bit wrap up on the last chapter- still something to fix, but the last chapter was too much, so, well, here it is.

Fixing the last chapter

Here are some minor things- not entirely bugs, but better to fix them.

vDOM Comparison

In javascript, two functions are equal only if they are the same, stays unequal even if they have the same procedure, that is,

const a = () => 1;
const b = () => 1;
a === b; // false
Enter fullscreen mode Exit fullscreen mode

So, when it comes to vDOM Comparison, we should skip the function comparison. Here is the fix,

for (let i = 0; i < aKeys.length; i++) {
    const key = aKeys[i]
    if (key === 'key') continue
    if (aProps[key] instanceof Function && bProps[key] instanceof Function) continue
    if (aProps[key] !== bProps[key]) return false
}
for (let i = 0; i < bKeys.length; i++) {
    const key = bKeys[i]
    if (key === 'key') continue
    if (aProps[key] instanceof Function && bProps[key] instanceof Function) continue
    if (aProps[key] !== bProps[key]) return false
}
Enter fullscreen mode Exit fullscreen mode

Handling CSS

Style should be treated as a special property that is attributed to element with .style property. Here is the fix,

export type VDomAttributes = { 
    key?: string | number
    style?: object
    [_: string]: unknown | undefined
}

export function createDom(vDom: VDomNode): HTMLElement | Text {
    if (isElement(vDom)) {
        const element = document.createElement(vDom.tag)
        Object.entries(vDom.props ?? {}).forEach(([name, value]) => {
            if (value === undefined) return
            if (name === 'key') return
            if (name === 'style') {
                Object.entries(value as Record<string, unknown>).forEach(([styleName, styleValue]) => {
                    element.style[styleName as any] = styleValue as any
                })
                return
            }
            if (name.startsWith('on') && value instanceof Function) {
                element.addEventListener(name.slice(2).toLowerCase(), value as EventListener)
            } else {
                element.setAttribute(name, value?.toString() ?? "")
            }
        })
        return element
    } else {
        return document.createTextNode(vDom)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now these side fixes are done, let's move on to the main topic of this chapter- Hooks.

Capsuling vDOM Creation

We previously explicitly called render(vDom, app!), which requires creating vDOM by user, here is a better way to do it.

import { mount, useState, type FuncComponent } from "./runtime";
import { createElement, fragment, VDomAttributes, VDomNode } from "./v-dom";

const App: FuncComponent = (props: VDomAttributes, __: VDomNode[]) => {
    const [cnt, setCnt] = useState(0)
    return <div>
        <button onClick={() => setCnt(cnt() + 1)}>Click me</button>
        <p>Count: {cnt()}</p>
    </div>
}
const app = document.getElementById('app')
mount(App, {}, [], app!)
Enter fullscreen mode Exit fullscreen mode
let reRender: () => void = () => {}
export function mount(app: FuncComponent, props: VDomAttributes, children: VDomNode[], parent: HTMLElement) {
    reRender = () => {
        const vDom = app(props, children) as unknown as VDomNode
        render(vDom, parent)
    }
    reRender()
}
Enter fullscreen mode Exit fullscreen mode

Looks better more or less. Now let's move on to the main topic of this chapter- Hooks.

useState

Okay, let's get to the hook. The first hook we are going to implement is useState. It is a hook that allows us to manage the state of a component. We can have the following signature for useState,

Note that our implementation is slightly different from the original React. We are going to return a getter and a setter function, instead of returning the state directly.

function useState<T>(initialValue: T): [() => T, (newValue: T) => void] {
    // implementation
}
Enter fullscreen mode Exit fullscreen mode

So where will we hook the value at? If we just hide it within the closure itself, the value will be lost when the component is re-rendered. If you insist on doing so, you need to access the space of the outer function, which is not possible in javascript.

So our way is to store it, you guessed it, in fiber. So, let's add a field to the fiber.

interface Fiber {
    parent: Fiber | null
    sibling: Fiber | null
    child: Fiber | null
    vDom: VDomNode
    dom: HTMLElement | Text | null
    alternate: Fiber | null
    committed: boolean
    hooks?: {
        state: unknown[]
    },
    hookIndex?: {
        state: number
    }
}
Enter fullscreen mode Exit fullscreen mode

And we only mount hooks to the root fiber, so we can add the following line to the mount function.

export function render(vDom: VDomNode, parent: HTMLElement) {
    wip = {
        parent: null,
        sibling: null,
        child: null,
        vDom: vDom,
        dom: null,
        committed: false,
        alternate: oldFiber,
        hooks: oldFiber?.hooks ?? {
            state: []
        },
        hookIndex: {
            state: 0
        }
    }
    wipParent = parent
    nextUnitOfWork = wip
}
Enter fullscreen mode Exit fullscreen mode

Hook index will come into use later. Now, hook index resets every time the component is re-rendered, but old hooks are carried over.

Please note that, we rendering component vDOM, only old fiber is accessible, so we can only manipulate that variable. However, it is null at the very beginning, so let's set up a dummy.

export function mount(app: FuncComponent, props: VDomAttributes, children: VDomNode[], parent: HTMLElement) {
    reRender = () => {
        if(!oldFiber) {
            oldFiber = {
                parent: null,
                sibling: null,
                child: null,
                vDom: '' as any,
                dom: null,
                committed: false,
                alternate: null,
                hooks: {
                    state: []
                },
                hookIndex: {
                    state: 0
                }
            }
        }
        const vDom = app(props, children) as unknown as VDomNode
        render(vDom, parent)
        console.log('rerender')
        console.log(vDom)
    }
    reRender()
}
Enter fullscreen mode Exit fullscreen mode

Now we will have big brain time- since the order of each hook call is fixed (you can not use hooks in a loop or a condition, basic React rule, you know why it is now), so we can use safely use hookIndex to access the hook.

export function useState<T>(initialState: T): [T, (newState: T) => void] {
    const hookIndex = oldFiber!.hookIndex!.state
    oldFiber!.hookIndex!.state++
    oldFiber!.hooks!.state[hookIndex] = oldFiber!.hooks!.state[hookIndex] ?? initialState
    console.log(oldFiber!.hooks!.state)
    const state = oldFiber!.hooks!.state[hookIndex]
    const setState = (newState: T) => {
        oldFiber!.hooks!.state[hookIndex] = newState
        reRender!()
    }
    return [state as T, setState]
}
Enter fullscreen mode Exit fullscreen mode

Well, let's try,

import { mount, useState, type FuncComponent } from "./runtime";
import { createElement, fragment, VDomAttributes, VDomNode } from "./v-dom";

const App: FuncComponent = (props: VDomAttributes, __: VDomNode[]) => {
    const [cnt, setCnt] = useState(0)
    return <div>
        <button onClick={() => setCnt(cnt + 1)}>Click me</button>
        <p>Count: {cnt}</p>
    </div>
}
const app = document.getElementById('app')
mount(App, {}, [], app!)
Enter fullscreen mode Exit fullscreen mode

It kind of works- the count increased from zero to one, but it does not increase further.

Well... strange right? Let's see what's going on, debug time.

const App: FuncComponent = (props: VDomAttributes, __: VDomNode[]) => {
    const [cnt, setCnt] = useState(0)
    return <div>
        <button onClick={() => {
            console.log(cnt + 1)
            console.log("FROM COMPONENT")
            setCnt(cnt + 1)
        }}>Click me</button>
        <p>Count: {cnt}</p>
    </div>
}
const app = document.getElementById('app')
mount(App, {}, [], app!)
Enter fullscreen mode Exit fullscreen mode

You will see that, it always log 1. But the web page tells us that it is 1, so it should be 2. What's going on?

For native types, javascript passes by value, so the value is copied, not referenced. In React class component, it requires you to have a state object to address the issues. In functional component, the React addresses with, closure. But if we were to use the latter, it requires a big change in our code. So a simple way to get pass is, using function to get the state, so that the function will always return the latest state.

export function useState<T>(initialState: T): [() => T, (newState: T) => void] {
    const hookIndex = oldFiber!.hookIndex!.state
    oldFiber!.hookIndex!.state++
    oldFiber!.hooks!.state[hookIndex] = oldFiber!.hooks!.state[hookIndex] ?? initialState
    console.log(oldFiber!.hooks!.state)
    const state = () => oldFiber!.hooks!.state[hookIndex] as T
    const setState = (newState: T) => {
        oldFiber!.hooks!.state[hookIndex] = newState
        reRender!()
    }
    return [state, setState]
}
Enter fullscreen mode Exit fullscreen mode

And now, here we got it! It works! We created the useState hook for our tiny React.

Summary

Okay, you may believe that this chapter is too short- hooks are important to react, so why did we only implement useState?

First, many hooks are just variations of useState. Such hook is irrelevant to the component it is called, for example, useMemo. Such things are just trivial works and we got no time to waste.

But, second, the most important reason, is that, for hooks like useEffect, under our current, root-update based frame, they are nearly impossible to do. When a fiber unmount, you can not signal, because we only fetch the global vDOM and update the whole vDOM, whereas, in real React, it's not like so.

In real React, functional components are updated by the parent component, so the parent component can signal the child component to unmount. But in our case, we only update the root component, so we can not signal the child component to unmount.

However, the current small project have basically demonstrated how react works, and I hope you find it helpful in gaining a better understanding of the react framework.

đź’– đź’Ş đź™… đźš©
fingerbone
Zend

Posted on October 20, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related