Implement React v18 from Scratch Using WASM and Rust – [8] Support Hooks

Implement React v18 from Scratch Using WASM and Rust – [8] Support Hooks

Based on big-react,I am going to implement React v18 core features from scratch using WASM and Rust.

Code Repository:https://github.com/ParadeTo/big-react-wasm

The tag related to this article:v8

The previous article implemented support for the FunctionComponent type, but it doesn’t support Hooks yet. In this article, we’ll use useState as an example to explain how to implement it.

If you frequently use React, you may have wondered about this: useState is imported from the react library, but its actual implementation is in react-reconciler. How is that achieved? Does React depend on react-reconciler?

To understand this issue, let’s analyze Big React.

First, let’s take a look at the entry file for useState:

// react/index.ts
import currentDispatcher, {
Dispatcher,
resolveDispatcher
} from ./src/currentDispatcher;

export const useState = <State>(initialState: (() => State) | State) => {
const dispatcher = resolveDispatcher() as Dispatcher;
return dispatcher.useState<State>(initialState);
};

export const __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = {
currentDispatcher
};

// react/src/currentDispatcher.ts

const currentDispatcher: { current: null | Dispatcher } = {
current: null
};

export const resolveDispatcher = () => {
const dispatcher = currentDispatcher.current;

if (dispatcher === null) {
console.error(resolve dispatcher时dispatcher不存在);
}
return dispatcher;
};

export default currentDispatcher;

The code is straightforward. When useState is executed, the core logic involves calling the useState method on currentDispatcher.current. It’s evident that currentDispatcher.current is initially set to null. So, where is it assigned a value? The answer lies in renderWithHooks:

// react-reconciler/src/fiberHooks.ts
export const renderWithHooks = (workInProgress: FiberNode) => {

currentDispatcher.current = HooksDispatcherOnMount

}

Moreover, the currentDispatcher here is not directly imported from react, but from the shared library. And shared ultimately imports __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED from react, which contains the currentDispatcher property:

// react-reconciler/src/fiberHooks.ts
import sharedInternals from shared/internals
const {currentDispatcher} = sharedInternals

// shared/internals.ts
import * as React from react
const internals = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
export default internals

// react/index.ts
export const __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = {
currentDispatcher,
}

So, it forms a dependency relationship like this:

react-dom —depend on–> react-reconciler —depend on–> shared —depend on–> react

During bundling, react and shared are bundled together into a react.js file. When bundling react-dom, react needs to be specified as an external dependency. This means that the resulting react-dom.js file won’t include the code for react but will treat it as an external dependency:

react + shared => react.js
react-dom + react-reconciler + shared => react-dom.js

This approach allows for easy replacement of the renderer. For example, if you want to implement react-noop for testing purposes:

react-noop + react-reconciler + shared => react-noop.js

However, it’s apparent that WASM builds don’t support externals. So, what can be done? Upon reconsideration, it’s realized that to meet the requirements mentioned above, two key points need to be addressed:

React and renderer code should be bundled separately.
The renderer should depend on React and be able to modify the values of variables in React at runtime.

We have already achieved the separation of bundling. Now, to implement the second point, which is modifying a variable’s value in one WASM module from another WASM module, we refer to the documentation of wasm-bindgen and discover that besides exporting functions from WASM for JavaScript usage, it’s also possible to import functions from JavaScript for WASM to invoke:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern “C” {
fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
// the alert is from JS
alert(&format!(“Hello, {}!”, name));
}

So, we can achieve the modification of a variable’s value in one WASM module from another by using JavaScript as an intermediary. The specific approach is as follows:

We export an updateDispatcher method from react to JavaScript, which is used to update CURRENT_DISPATCHER.current in react.

fn derive_function_from_js_value(js_value: &JsValue, name: &str) -> Function {
Reflect::get(js_value, &name.into()).unwrap().dyn_into::<Function>().unwrap()
}

#[wasm_bindgen(js_name = updateDispatcher)]
pub unsafe fn update_dispatcher(args: &JsValue) {
let use_state = derive_function_from_js_value(args, “use_state”);
CURRENT_DISPATCHER.current = Some(Box::new(Dispatcher::new(use_state)))
}

Then, we declare the import of this method in react-reconciler (for simplicity, we omitted importing from shared here):

#[wasm_bindgen]
extern “C” {
fn updateDispatcher(args: &JsValue);
}

During render_with_hooks, the updateDispatcher is called, passing an Object that contains the use_state property:

fn update_mount_hooks_to_dispatcher() {
let object = Object::new();

let closure = Closure::wrap(Box::new(mount_state) as Box<dyn Fn(&JsValue) -> Vec<JsValue>>);
let function = closure.as_ref().unchecked_ref::<Function>().clone();
closure.forget();
Reflect::set(&object, &“use_state”.into(), &function).expect(“TODO: panic set use_state”);

updateDispatcher(&object.into());
}

Finally, we need to insert a piece of code at the top of the bundled react-dom/index_bg.js file to import the updateDispatcher method from react:

import {updateDispatcher} from react

Certainly, this step can be implemented using a script.

To summarize, the above process can be represented simply as:

The details of this update can be found here.

Let’s test it by modifying the hello-world example:

import {useState} from react

function App() {
const [name, setName] = useState(() => ayou)
setTimeout(() => {
setName(ayouayou)
}, 1000)
return (
<div>
<Comp>{name}</Comp>
</div>
)
}

function Comp({children}) {
return (
<span>
<i>{`Hello world, ${children}`}</i>
</span>
)
}

export default App

The result is shown below:

It looks strange, right? That’s because we haven’t fully implemented the update process yet.

So far, we have replicated the Big React v3 version. Please kindly give it a star!

Leave a Reply

Your email address will not be published. Required fields are marked *