Build a Tiny React Ch04 Hooks
Zend
Posted on October 20, 2024
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
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
}
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)
}
}
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!)
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()
}
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
}
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
}
}
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
}
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()
}
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]
}
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!)
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!)
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]
}
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.
Posted on October 20, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.