React bindings for MoonBit
🚧 This is an early project
This is an experimental hobby project exploring MoonBit bindings for React. The API is unstable and may change frequently. Not recommended for production use. This project is intended for technical exploration and learning purposes only.
All public APIs are experimental. The virtual-node, element-helper, and basic hook APIs are the maintained baseline; React 19 concurrent hooks and the broad event catalogue are newer additions that should receive application-level testing before adoption. Deprecated compatibility APIs remain available only until the next breaking release.
JsObscure is the explicit escape hatch at the MoonBit/JavaScript boundary.
Use it for hook dependency values with obscure(value) and for intentional JS
interoperation only. Generic hook and component values cross React using the
MoonBit JavaScript representation, so they must be values that the generated
MoonBit runtime can pass directly; do not assume JSON serialization or deep
cloning occurs.
The browser entry point must initialize globalThis.React,
globalThis.ReactDOM, and globalThis.ReactDOMClient before calling this
package. (window and globalThis are the same global object in a browser.)
ReactDOM supplies DOM-specific Hooks such as use_form_status; the bundled
demo shows the supported ESM integration pattern.
Component functions must be placed in the virtual DOM through component, not
called directly. Use component_with_children when the component needs to
place caller-supplied children in its own tree.
Apply with_key to children in dynamic collections, using stable application
identities rather than array indexes.
render(vdom: VirtualNode, parent: @dom.Element) -> Unit- Render virtual DOM to specified parent elementrender_with_options(vdom: VirtualNode, parent: @dom.Element, options: RootOptions) -> Unit- Create and render through a configured React roothydrate_root(vdom: VirtualNode, parent: @dom.Element, options?: RootOptions) -> Unit- Attach React to matching server-rendered HTMLunmount(parent: @dom.Element) -> Unit- Unmount the React root for an elementcreate_portal(child: VirtualNode, parent: @dom.Element, key?: String) -> VirtualNode- Place DOM in another container while retaining React-tree context and event propagationrender_to_string(vdom: VirtualNode, identifier_prefix?: String) -> String- Basic synchronous SSR/SSG rendererrender_to_readable_stream(vdom: VirtualNode, options?: StreamRenderOptions) -> ReactReadableStream- Start React 19.2 progressive SSR on runtimes with Web StreamsStreamRenderOptions::new(...) -> StreamRenderOptions- Configure identifier prefixes, bootstrap scripts/modules, CSP nonce, and server error reportingReactReadableStream::{wait_all_ready, read_text, abort, to_js_readable_stream}- Wait for suspended content, consume HTML, cancel work, or integrate the one-shot Web Stream directlyRootOptions::new(...) -> RootOptions- ConfigureidentifierPrefixand caught, uncaught, and recoverable error callbacksReactError::message() -> String,ReactErrorInfo::component_stack() -> String- Inspect root callback valuescomponent[T](f: (T) -> VirtualNode, props: T, children: Array[VirtualNode]) -> VirtualNode- Create a leaf componentcomponent_with_children[T](f: (T, Array[VirtualNode]) -> VirtualNode, props: T, children: Array[VirtualNode]) -> VirtualNode- Create a component that places its childrendefine_component[T](render: (T) -> VirtualNode) -> ReactComponent[T]- Define a stable reusable component type with typed MoonBit propsReactComponent::render(props: T) -> VirtualNode- Render a reusable typed componentReactComponent::memo(are_props_equal?: (T, T) -> Bool) -> ReactComponent[T]- Memoize a typed component with default identity or a typed comparatorlazy_component[T](loader: async () -> ReactComponent[T]) -> ReactComponent[T]- Lazily load and cache a typed component through Suspensesuspense(fallback: VirtualNode, children: Array[VirtualNode]) -> VirtualNode- Display fallback UI until suspended children are readyactivity(mode: ActivityMode, children: Array[VirtualNode]) -> VirtualNode- React 19.2 boundary that hides and restores UI while retaining child stateresource_from_promise[T](promise: Promise[T]) -> ReactResource[T]- Preserve one cached JavaScript Promise as a typed React resourceresource_from_async[T](loader: async () -> T) -> ReactResource[T]- Start one MoonBit async operation outside render and preserve its Promise identityuse_resource[T](resource: ReactResource[T]) -> T- Read a cached resource with React 19use, suspending or throwing to the nearest boundaryerror_boundary(fallback, children, reset_key?, on_error?) -> VirtualNode- Render a local fallback for render/resource errors and retry when its reset key changescomponent_from_js[T](component: JsObscure) -> ReactComponent[T]- Declare the typed MoonBit props contract for a trusted JavaScript componentVirtualNode::with_key(key: String) -> VirtualNode- Assign a stable React reconciliation key without adding a DOM wrappercreate_context[T](default_value: T) -> ReactContext[T]- Create a typed React ContextReactContext::provider(value: T, children: Array[VirtualNode]) -> VirtualNode- Provide a value without adding a DOM wrapperflush_sync(callback: () -> Unit) -> Unit- Force callback updates into the DOM before returning for rare third-party integrationsprefetch_dns,preconnect,preload,preload_module,preinit,preinit_module- Emit React-managed browser resource hintsPreloadOptions,ModuleHintOptions,PreinitOptions- Configure typed destination, CORS, priority, integrity, CSP, image, module, and stylesheet-precedence metadata
For example, use component(my_component, props, []), never
my_component(props) directly in a virtual DOM tree.
use_state[T](initial: T) -> (T, (T) -> Unit)- State management hookuse_context[T](context: ReactContext[T]) -> T- Read and subscribe to the nearest typed Context provideruse_state_with_updater[T](initial: T) -> (T, (StateUpdate[T]) -> Unit)- State hook with direct and functional updatesuse_reducer_with_initial[S, A](initial: S, reducer: (S, A) -> S) -> (S, (A) -> Unit)- Reducer hook for any explicit state typeuse_reducer[S: Default, A](initial?: S, reducer: (S, A) -> S) -> (S, (A) -> Unit)- Reducer hookuse_effect_once(effect: () -> Unit) -> Unit- Effect hook that runs only onceuse_effect_cleanup_deps(effect: () -> () -> Unit, deps: Array[JsObscure]) -> Unit- Effect hook with a cleanup functionuse_effect_once_with_cleanup(effect: () -> () -> Unit) -> Unit- One-time effect with a cleanup functionuse_effect_deps(effect: () -> Unit, deps: Array[JsObscure]) -> Unit- Effect hook with dependenciesuse_layout_effect_deps(effect: () -> Unit, deps: Array[JsObscure]) -> Unit- Layout effect hookuse_memo_deps[A](factory: () -> A, deps: Array[JsObscure]) -> A- Memoization hookuse_callback_deps[F](callback: F, deps: Array[JsObscure]) -> F- Callback memoization hookuse_effect_event[F](callback: F) -> F- React 19.2 effect-only callback that reads latest state without re-subscribing an effectuse_action_state[S, A](initial: S, action: (S, A) -> S) -> (S, (A) -> Unit, Bool)- React 19 action state, dispatch, and pending flaguse_async_action_state[S, A](initial: S, action: async (S, A) -> S) -> (S, (A) -> Unit, Bool)- Export a MoonBit async reducer as a Promise-returning React Actionuse_optimistic[S, A](value: S, reducer: (S, A) -> S) -> (S, (A) -> Unit)- Temporary optimistic state scoped to a React Actionuse_form_status() -> FormStatus- Read pending data, method, and action from the nearest parent formuse_callback0_deps(f: () -> Unit, deps: Array[JsObscure]) -> () -> Unit- Zero-argument callback hookuse_ref[T](initial: T) -> ReactRef[T]- Reference hookuse_dom_ref() -> ReactDomRef- Nullable DOM-element ref hook whosecurrent()value isNonebefore mount and after unmountuse_id() -> String- Stable component-local identifier hookuse_deferred_value[T](value: T) -> T- Deferred-value hook for non-urgent renderinguse_transition() -> (Bool, (() -> Unit) -> Unit)- Transition state and starter hookstart_transition(action: () -> Unit) -> Unit- Start a transition when pending state is not neededuse_sync_external_store[T](subscribe, get_snapshot, get_server_snapshot?) -> T- Read and subscribe to a typed immutable external-store snapshotuse_imperative_ref[T]() -> ImperativeRef[T]- Create a nullable typed ref for a custom component handleuse_imperative_handle_deps[T](ref: ImperativeRef[T], create_handle: () -> T, deps: Array[JsObscure]) -> Unit- Expose a typed React 19 imperative handleobscure[T](v: T) -> JsObscure- Dependency conversion helper function
div,span,p,h1,h2,h3- Basic text elementsform,button,input,textarea,select,option- Form elementsa,img,video,audio- Media and link elementsul,ol,li- List elementssection,article,header,footer,nav,aside- Semantic elementslabel- Label element- Generated table helpers:
table,caption,colgroup,col,thead,tbody,tfoot,tr,th, andtd - Generated form and interactive helpers:
fieldset,legend,datalist,optgroup,output,progress,meter,dialog,details, andsummary - Generated React 19 metadata helpers:
title,meta,link,style_tag, andscript_tag - Generated core SVG helpers include
svg,g,defs,symbol,path,circle,ellipse,rect,line, gradients, clipping/masking, text, anduse_
The generated set contains 54 helpers across six categories. Every generated
helper includes typed common role, title, tab_index, hidden,
aria_label, and data_testid props plus the applicable tag-specific props.
SVG and metadata names use React camel case at the JavaScript boundary, such as
viewBox, strokeWidth, httpEquiv, and xlinkHref.
DOMEventType covers clipboard, composition, keyboard, mouse, pointer, wheel,
form, drag, touch, media, animation, and transition events. It maps each value
to React's camel-cased onXxx property automatically.
DOMEventtype and its methods:target_value() -> String- Get form element valuetarget_checked() -> Bool- Get checkbox or radio checked statenative_pointer_event() -> @dom.PointerEvent?,native_wheel_event() -> @dom.WheelEvent?- Read checked native payloads; mismatched or incomplete payloads returnNonekey() -> String,key_code() -> Int- Keyboard eventsclient_x() -> Int,client_y() -> Int- Mouse coordinatesprevent_default(),stop_propagation()- Event controlctrl_key(),shift_key(),alt_key(),meta_key() -> Bool- Modifier key detection
Use native_event() for the opaque native-event view. Keep calling
prevent_default() and stop_propagation() on the React DOMEvent so React's
SyntheticEvent semantics are preserved. For React form Actions,
ReactFormData::to_dom_form_data() exposes dom-ffi's text/file-aware FormData
without copying the underlying object.
ElementAttrs- HTML attribute management, including typed string, boolean, integer, floating-point, and JavaScript-value settersElementEvents- Event handler managementRespoStyle- CSS styles (from@cssmodule)InputTypeenum - Support for all HTML input typesStateUpdate[T]-Set(value)orUpdate(fn(previous) { ... })for safely deriving state from the latest React value
innerHTML parameters are converted to React's dangerouslySetInnerHTML API.
Only pass trusted, sanitized HTML through this escape hatch. Combining
innerHTML with children is rejected before the element reaches React.
Use ElementAttrs::set for string attributes, set_bool for React boolean
properties such as disabled, set_int for integral properties such as
rows, and set_float for fractional numeric properties such as progress and
meter values. The built-in element helpers use these typed conversions
automatically.
Use set_js_value only for explicit React values such as a DOM ref:
attrs.set_js_value("ref", input_ref.to_js_obscure()).
For controlled checkbox or radio inputs, use input(checked=value) so both
true and false reach React as booleans.
Use default_value and default_checked for uncontrolled form elements;
combining them with value or checked is rejected. For DOM elements, prefer
use_dom_ref() and attach its JavaScript ref object through set_js_value.
ReactDomRef::current() returns Some(element) only while the element is
mounted.
Controlled fields require on_change, or an explicit read_only=true or
disabled=true state. A multiple=true select uses the values or
default_values array parameters; individual options do not accept
selected.
React 19 function Actions use the typed FormAction wrapper. Wrap the
ReactFormData dispatcher returned by use_action_state or
use_async_action_state with form_action(dispatch), or convert a direct
MoonBit async form function with async_form_action. Pass the result through
form(action=...), button(form_action=...), or a submit/image
input(form_action=...). use_form_status must run in a child component below
the form it observes. The async bridge is pinned to moonbitlang/async@0.20.3,
the newest release compatible with this repository's pinned MoonBit compiler.
declare_contained_style is deprecated; use contained_static_style instead.
ReactRef::from is deprecated because its constructor-like name hides a Hook
call; use use_ref or use_dom_ref at the top level of a component.
Static-style declarations are safe during server-side rendering or pre-rendering: without a browser document they return their deterministic class name without injecting a tag. The browser's module evaluation then performs the injection.
flush_sync is a last-resort integration escape hatch. It guarantees that
updates scheduled inside its callback are reflected in the DOM by the next
line, but it may also flush pending work, run Effects, or reveal Suspense
fallbacks. Use it in an event or browser/third-party callback only; do not call
it while React is rendering or running an Effect, and do not replace normal
React batching with it.
The six resource-hint helpers mirror React 19.2:
prefetch_dnsresolves a host speculatively;preconnectadditionally asks the browser to open an early connection.preloadandpreload_moduledownload a classic resource or ESM module without applying/evaluating it.preinitandpreinit_moduledownload and immediately apply a stylesheet or evaluate a classic/ESM script when ready.
Use PreloadOptions::new with a typed PreloadDestination. Fetch destinations
must supply cross_origin; the image source-set and sizes fields are image-only.
Use PreinitOptions::script or PreinitOptions::style; the style constructor
requires typed precedence so a required React option cannot be omitted. Module
hints always use as: "script" internally.
React deduplicates equivalent hints. Browser calls may be made during render, Effects, or events; server-rendered hints only take effect during component rendering or async work originating from it. Frameworks commonly manage resource discovery, ordering, and deduplication already, so consult the framework documentation before calling these APIs directly.
create_portal changes physical DOM placement only: context and events still
follow the owning React tree. render_with_options applies its options only
when it creates a root; later calls for the same parent reuse that root.
The supported SSR/SSG paths are deliberately explicit:
render_to_stringis a synchronousreact-dom/serverbinding.render_to_readable_streamresolves when the shell is ready and supports progressive Suspense output on runtimes that implement Web Streams andAbortController.wait_all_readydelays static generation until all suspended content is ready;read_textandto_js_readable_streamexpose the same one-shot body, so choose one consumption path.abortcancels pending server work and leaves unresolved boundaries for client recovery. UseStreamRenderOptions::new(on_error=...)to observe server failures.hydrate_rootrequires markup identical to the initial client tree.identifier_prefixmust be identical between either server renderer andRootOptions::newwhen the tree usesuse_id.- React Server Components and framework routing/data protocols are not provided.
For a server or build entry, initialize only the renderer globals it needs:
import * as React from "react";
import * as ReactDOMServer from "react-dom/server";
globalThis.React = React;
globalThis.ReactDOMServer = ReactDOMServer;Then initialize React, ReactDOM, and ReactDOMClient in the browser entry
before calling hydrate_root. Do not bundle react-dom/server into ordinary
client code merely to hydrate markup that was already generated on the server.
use_sync_external_store follows React's immutable snapshot contract. Keep the
subscribe function stable across renders, return an unsubscribe callback, and
return the same snapshot value while the store has not changed. When rendering
on the server, provide get_server_snapshot and return the same initial value
during hydration.
ReactComponent[T] carries a single typed MoonBit props value. A component
created through component_from_js receives that value as
props.moonbitProps; this explicit carrier avoids spreading or depending on
MoonBit's generated JavaScript object representation. memo compares this
single value with Object.is by default, or receives previous and next typed
values in its custom comparator.
Declare memoized and lazy components outside ordinary render paths. A
lazy_component loader resolves to ReactComponent[T]; React caches both the
loader Promise and its resolved component. Render it below suspense so a
fallback is visible while loading.
React 19 makes refs available as component props, so this binding intentionally
does not add a new forwardRef wrapper. Pass ImperativeRef[T] through typed
props and call use_imperative_handle_deps in the child. current() returns
None before commit and after cleanup. Prefer declarative props whenever the
behavior does not genuinely require an imperative handle.
Create a ReactResource[T] outside component render with
resource_from_promise or resource_from_async, then call use_resource while
rendering below suspense. Reuse the same resource identity across retries;
creating a new Promise during every render repeatedly suspends and triggers
React's uncached-Promise warning. Rejections flow to the nearest
error_boundary. Change its reset_key after selecting a new cached resource
to retry. The optional on_error receives React's component stack; root error
callbacks remain separate observability hooks. Error Boundaries do not catch
event-handler failures or arbitrary asynchronous errors outside render.
请在组件 render 之外通过 resource_from_promise 或 resource_from_async
创建 ReactResource[T],并在 suspense 内调用 use_resource。重试时必须复用
稳定 resource;每次 render 新建 Promise 会反复 suspend,并触发 React 的
uncached-Promise warning。rejection 会进入最近的 error_boundary;选择新的缓存
resource 后改变 reset_key 即可重试。on_error 用于局部组件栈观测,root error
callback 仍是独立观测点。Error Boundary 不捕获事件处理器或 render 外任意异步错误。
activity(ActivityMode::Hidden, children) keeps its child state and DOM tree,
but React hides that DOM with display: none, cleans up Effects, and
deprioritizes hidden updates. Switching the same boundary back to Visible
restores the preserved state and remounts Effects. Use stable child keys when an
Activity contains dynamic collections.
activity(ActivityMode::Hidden, children) 会保留子组件 state 与 DOM 树,但
React 会用 display: none 隐藏 DOM、清理 Effects,并降低隐藏更新的优先级。
切回 Visible 后,原 state 会恢复且 Effects 会重新挂载;动态列表仍需使用稳定 key。
VirtualNode- Base virtual node typeVirtualElement- Virtual element typeText(String)- Text node
Create an empty directory and add the six files below. This example consumes
the published tiye/react@0.4.0 with tiye/dom-ffi@0.4.0. Use Node 22.12 or
newer in the Node 22 series and the MoonBit toolchain pinned in CI
(0.10.4+2cc641edf, including its matching core).
新建空目录,添加下面六个文件,再执行末尾的命令即可运行计数器。示例显式处理 挂载节点缺失,并在 React globals 初始化后加载 MoonBit 生成代码。CI 会直接提取 这些代码块,安装依赖、编译并验证真实浏览器中的点击更新。
name = "example/react-counter"
version = "0.0.0"
import {
"tiye/react@0.4.0",
"tiye/dom-ffi@0.4.0",
}
preferred_target = "js"import {
"tiye/react" @react,
"tiye/dom-ffi" @dom,
}
pkgtype(kind: "executable"){
"name": "react-counter",
"private": true,
"type": "module",
"scripts": {
"build:moon": "moon build --target js --debug",
"dev": "npm run build:moon && vite",
"build": "npm run build:moon && vite build"
},
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"vite": "8.2.2"
}
}<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MoonBit React counter</title>
<link rel="icon" href="data:," />
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.mjs"></script>
</body>
</html>import * as React from "react";
import * as ReactDOM from "react-dom";
import * as ReactDOMClient from "react-dom/client";
globalThis.React = React;
globalThis.ReactDOM = ReactDOM;
globalThis.ReactDOMClient = ReactDOMClient;
// Dynamic import guarantees that React globals exist before MoonBit starts.
await import("./_build/js/debug/build/react-counter.js");///|
priv struct CounterProps {}
///|
fn comp_counter(_props : CounterProps) -> @react.VirtualNode {
let (count, set_count) = @react.use_state_with_updater(0)
@react.button(
id="counter",
on_click=fn(_) {
set_count(@react.Update(fn(previous) { previous + 1 }))
},
[@react.Text("Count: \{count}")],
)
}
///|
fn main {
match @dom.window().document().get_element_by_id("app") {
Some(root) =>
@react.render(
@react.component(comp_counter, CounterProps::{}, []),
root,
)
None => @dom.error_log("Missing #app mount element; rendering skipped")
}
}From that directory, run:
moon update
npm install
moon check --target js
npm run devOpen the local URL printed by Vite. The button starts at Count: 0 and
increments on each click. After editing MoonBit, rerun npm run build:moon
(or run moon build --target js --debug --watch in a second terminal).
npm run build creates the production site in dist/.
Repository maintainers can run yarn check:quick-start to extract these exact
six files and verify dependency resolution, compilation, production build,
three click updates, and the explicit missing-root error. This checks the
published versions in the example; the library's own tests cover the current
branch. The check needs registry access and an installed Playwright Chromium.
- Type-safe virtual DOM construction
- React-style hooks (useState)
- CSS-in-JS styling support
- Event handling
- Component composition
The package follows the current MoonBit manifest format (moon.mod and
moon.pkg). To run the checks locally:
moon check
moon test
moon info
corepack yarn install --frozen-lockfile
corepack yarn generate:dom
corepack yarn check:generated
corepack yarn check:docs
corepack yarn test:server
corepack yarn build
corepack yarn test:browserCI pins MoonBit compiler 0.10.4+2cc641edf and validates Node 22 with Yarn
1.22.22. check:docs automatically discovers explicit public declarations in
the library package instead of relying on an API allowlist; the current gate
requires every discovered declaration to carry doc comments (currently
235/235). check:quick-start also compiles and exercises the README example
as an independent consumer. test:server covers
progressive Suspense chunks, all-ready static
output, abort/error behavior, bootstrap metadata, and identifier prefixes
against the real React server renderer. test:browser runs deterministic
TodoMVC and React runtime conformance
flows in Chromium, including StrictMode lifecycle, batching, root reuse, DOM
ref cleanup, async form Actions, form status, optimistic rollback, portals,
root error callbacks, synchronous and Web Stream server rendering, hydration,
external-store
subscription lifecycles, memo/lazy/Suspense, typed JavaScript interoperation,
imperative handles, Activity state/Effect lifecycles, cached resources and
local Error Boundary retry paths, and generated
HTML/SVG/metadata DOM behavior; install it
once locally with yarn playwright install chromium.
Update the toolchain pin only through the full check, unit-test, interface,
browser-test, and browser-build matrix. CI verifies this revision with the
pinned MoonBit toolchain, React 19.2.8, and Vite 8.2.x.
The browser entry point loads React, ReactDOM, ReactDOMClient, and the demo-only
server renderer before the generated MoonBit application. render can be
called again for the same parent element; the binding reuses its React root
rather than creating a second one.
Prepare releases on a branch by aligning moon.mod, package.json, the dated
Changelog section, and bilingual release-notes/vX.Y.Z.md. Run
yarn check:release; it requires exact version agreement, complete historical
tag coverage, an empty Unreleased section, and an exact 18-file package
allowlist.
After the preparation PR is merged, create an annotated vX.Y.Z tag on
main, then create and publish a GitHub Release whose title exactly matches
that tag. The release.published event triggers Publish release. It verifies
tag identity and main ancestry, generated sources, MoonBit
check/test/interface/format results, real Chromium conformance, the browser
build, and the package allowlist before uploading the archive and
SHA256SUMS to the Release.
The same workflow publishes to mooncakes.io using the repository or inherited
organization Secret MOON_CREDENTIALS; registry credentials never leave the
ephemeral Actions runner. The publish command intentionally is not frozen:
MoonBit validates the archive from a fresh extracted module that must install
its declared dependencies. All source/package inputs are frozen and checked by
the preceding release gates. The workflow then resolves tiye/react@X.Y.Z from
a fresh temporary consumer, runs JS check/build, verifies exact Node SSR output,
and exercises client render, DOM-reusing hydration, and post-hydration state
updates in Chromium. It requires zero browser diagnostics and preserves the
exact logs as an Actions artifact. A release is not complete until this
downstream runtime check passes.
Release source is always checked out from the immutable tag. The downstream
verifier is sparse-checked out separately from github.workflow_sha, so a
verifier bug can be corrected without changing or republishing tagged source.
Use the workflow's workflow_dispatch input only to recover or verify an
already-published GitHub Release, such as one created before the workflow was
installed. The separate Verify published package workflow remains
available for registry-only rechecks.
Apache 2.0