Warning
This library is currently under active development. The API may change at any time. Use at your own risk. Please report any issues or feature requests on the issues page.
mono-jsx-dom is a JSX runtime for building web user interface.
- β‘οΈ Use browser-specific APIs, no virtual DOM
- π¦ Lightweight (4KB gzipped), zero dependencies
- π¦ Signals as reactive primitives
- π‘ Complete Web API TypeScript definitions
- β³ Streaming rendering
- π¨ Builtin TailwindCSS integration
- π© Builtin dev/build/deploy toolchain
Playground: https://val.town/x/ije/mono-jsx-dom
You can run the mono-jsx-dom int command to initialize a project with mono-jsx-dom boilerplate.
# node
npx mono-jsx-dom init
# bun
bunx --bun mono-jsx-dom initmono-jsx-dom adds a mount method to the HTMLElement prototype to allow you to mount the UI to the DOM.
// app.tsx
async function App(this: FC<{ word: string }>) {
this.word = await fetch("/data/word").then(res => res.text());
return <div>Hello, {this.word}!</div>;
}
document.body.mount(<App />);You can also define a component as a custom element with the register function:
// app.tsx
import { register } from "mono-jsx-dom";
function App(this: FC<{ word: string }>) {
return <div>Hello, {this.word}!</div>;
}
register("my-app", App, { mode: "open", style: "div { color: black; }" });Then you can use the <my-app> element in your HTML:
<my-app word="world"></my-app>
<script type="module" src="app.tsx"></script>Tip
mono-jsx-dom is designed for client-side rendering. You can use mono-jsx to render the UI on the server side.
mono-jsx-dom uses JSX to describe the user interface, similar to React but with key differences.
mono-jsx-dom adopts standard HTML property names, avoiding React's custom naming conventions:
classNameβclasshtmlForβforonChangeβonInput
mono-jsx-dom allows you to compose the class property using arrays of strings, objects, or expressions:
<div
class={[
"container box",
isActive && "active",
{ hover: isHover },
]}
/>;mono-jsx-dom supports pseudo-classes, pseudo-elements, media queries, and CSS nesting in the style property:
<a
style={{
display: "inline-flex",
gap: "0.5em",
color: "black",
"::after": { content: "β©οΈ" },
":hover": { textDecoration: "underline" },
"@media (prefers-color-scheme: dark)": { color: "white" },
"& .icon": { width: "1em", height: "1em" },
}}
>
<img class="icon" src="link.png" />
Link
</a>;mono-jsx-dom uses <slot> elements to render slotted content (equivalent to React's children property). You can also add the name prop to define named slots:
function Container() {
return (
<div class="container">
{/* Default slot */}
<slot />
{/* Named slot */}
<slot name="desc" />
</div>
)
}
function App() {
return (
<Container>
{/* This goes to the named slot */}
<p slot="desc">This is a description.</p>
{/* This goes to the default slot */}
<h1>Hello world!</h1>
</Container>
)
}mono-jsx-dom injects a global html tag function to allow you to render raw HTML, which is similar to React's dangerouslySetInnerHTML.
function App() {
const title = "Hello world!";
return <div>{html`<h1>${title}</h1>`}</div>;
}Variables in the html template literal are escaped. To render raw HTML without escaping, call the html function with a string literal.
function App() {
const title = "<span style='color: blue;'>Hello world!</span>";
return <div>{html(`<h1>${title}</h1>`)}</div>;
}You can also use css and js functions for CSS and JavaScript:
function App() {
return (
<head>
<style>{css`h1 { font-size: 3rem; }`}</style>
<script>{js`console.log("Hello world!")`}</script>
</head>
)
}Warning
The html tag function is unsafe and can cause XSS vulnerabilities.
mono-jsx-dom lets you write event handlers directly in JSX, similar to React:
function Button() {
return (
<button onClick={(evt) => alert("BOOM!")}>
Click Me
</button>
)
}mono-jsx-dom allows you to use a function as the value of the action prop of the <form> element. The function will be called on form submission, and the FormData object will contain the form data.
function App() {
return (
<form action={(data: FormData) => console.log(data.get("name"))}>
<input type="text" name="name" />
<button type="submit">Submit</button>
</form>
)
}mono-jsx-dom supports async components that return a Promise or are declared as async functions. With streaming rendering, async components are rendered asynchronously, allowing you to fetch data or perform other async operations before rendering the component.
async function JsonViewer(props: { url: string }) {
const data = await fetch(props.url).then((res) => res.json());
return <ObjectViewer data={data} />;
}
function App() {
return (
<JsonViewer url="https://example.com/data.json" />
)
}
document.body.mount(<App />);You can use pending to display a loading state while waiting for async components to render:
async function Sleep({ ms }) {
await new Promise((resolve) => setTimeout(resolve, ms));
return <slot />;
}
function App() {
return (
<Sleep ms={1000} pending={<p>Loading...</p>}>
<p>After 1 second</p>
</Sleep>
)
}
document.body.mount(<App />);You can add the catch prop to a function component. This allows you to catch errors in components and display a fallback UI:
async function Hello() {
throw new Error("Something went wrong!");
return <p>Hello world!</p>;
}
function App() {
return (
<Hello catch={err => <p>{err.message}</p>} />
)
}
document.body.mount(<App />);The catch prop should be a function that gets the caught error as the first argument and returns a JSX element.
mono-jsx-dom uses signals to update the view when a signal changes. Signals are similar to React's state, but they are lighter-weight and more efficient. You can use signals to manage state in your components.
You can use the this keyword in your components to manage signals. Signals are bound to the component instance, can be updated directly, and automatically re-render the view when they change:
function Counter(this: FC<{ count: number }>, props: { initialCount?: number }) {
// Initialize a signal
this.count = props.initialCount ?? 0;
// or you can use `this.init` to initialize the signals
this.init({ count: props.initialCount ?? 0 });
return (
<div>
{/* render signal */}
<span>{this.count}</span>
{/* Update signal to trigger re-render */}
<button onClick={() => this.count--}>-</button>
<button onClick={() => this.count++}>+</button>
</div>
)
}Call this.atom(initialValue) inside a component to get a reactive atom tied to that instance. It updates the view when you call set, and you can read the current value with get (for example in effect callbacks).
function Counter(this: FC) {
const count = this.atom(0);
this.effect(() => {
console.log("count changed:", count.get());
});
return (
<div>
<span>{count}</span>
<button onClick={() => count.set((prev) => prev + 1)}>Increment</button>
</div>
);
}For state shared across the whole app (or a module), import atom and define it at the top level. Any component that reads that atom in JSX or effects stays in sync.
import { atom } from "mono-jsx-dom";
const count = atom(0);
const double = count.ref((value) => value * 2);
function Counter(this: FC) {
this.effect(() => {
console.log("count changed:", count.get());
});
return (
<div>
<p>count: {count}</p>
<p>double: {double}</p>
</div>
);
}
function App(this: FC) {
return (
<div>
<Counter />
<button onClick={() => count.set((prev) => prev + 1)}>Increment</button>
</div>
);
}The type of atom signals is defined as follows:
export interface Atom<T> {
/** Read the current value. */
get(): T;
/** Assign a new value or compute one from the previous value. */
set(value: T): void;
set(fn: (value: T) => T): void;
/** When `T` is an array, map each item to a child for list rendering. */
map(
callback: (value: T extends (infer V)[] ? V : T, index: number) => ChildPrimitiveType,
): ChildPrimitiveType[];
/** Create signal ref to the atom. */
ref(): T;
/** Derived reactive value from the atom. */
ref<V>(callback: (value: T) => V): V;
/** Run `callback` when the atom changes; pass `signal` to tie lifetime to an `AbortSignal`. */
watch(callback: () => void, signal?: AbortSignal): void;
}The this.store({ ... }) method in a component builds a reactive object: plain fields are signals, and getters become derived signals.
function App(this: FC) {
const counter = this.store({
value: 0,
get double() {
return this.value * 2;
},
});
return (
<div>
<span>count: {counter.value}</span>
<span>double: {counter.double}</span>
<button onClick={() => counter.value++}>+</button>
</div>
);
}You can also use the store function to create a global signal store. Like atom function, the global signals store is shared between all components.
import { store } from "mono-jsx-dom";
const counter = store({
value: 0,
get double() {
return this.value * 2;
},
});
function Counter(this: FC) {
return (
<div>
<span>{counter.value}</span>
<span>{counter.double}</span>
</div>
);
}
function App(this: FC) {
return (
<div>
<Counter />
<button onClick={() => counter.value++}>Increment</button>
</div>
);
}You can use this.computed to create a derived signal based on other signals:
function App(this: FC<{ input: string }>) {
this.input = "Welcome to mono-jsx";
return (
<div>
<h1>{this.computed(() => this.input + "!")}</h1>
<input type="text" $value={this.input} />
</div>
)
}Tip
You can use this.$ as a shorthand for this.computed to create computed signals.
You can use this.effect to perform side effects in components. The effect runs when the component is mounted, automatically collects used signals as dependencies, and reruns when those dependencies change.
function App(this: FC<{ count: number }>) {
this.count = 0;
this.effect(() => {
console.log("Count:", this.count);
});
return (
<div>
<span>{this.count}</span>
<button onClick={() => this.count++}>+</button>
</div>
)
}The callback function of this.effect can return a cleanup function that runs once the component's element has been removed through <show>, <hidden>, or <switch> conditional rendering:
function Counter(this: FC<{ count: number }>) {
this.count = 0;
this.effect(() => {
const interval = setInterval(() => {
this.count++;
}, 1000);
return () => clearInterval(interval);
});
return (
<div>
<span>{this.count}</span>
</div>
)
}
function App(this: FC<{ show: boolean }>) {
return (
<div>
<show when={this.show}>
<Counter />
</show>
<button onClick={() => this.show = !this.show }>{this.$(() => this.show ? 'Hide': 'Show')}</button>
</div>
)
}The <show> element conditionally renders content based on the when prop. You can use signals to control the visibility of the content on the client side.
function App(this: FC<{ show: boolean }>) {
const toggle = () => {
this.show = !this.show;
}
return (
<div>
<show when={this.show}>
<h1>Welcome to mono-jsx!</h1>
</show>
<button onClick={toggle}>
{this.$(() => this.show ? "Hide" : "Show")}
</button>
</div>
)
}mono-jsx-dom also provides a <hidden> element that is similar to <show>, but it conditionally hides the content based on the when prop.
function App(this: FC<{ hidden: boolean }>) {
return (
<div>
<hidden when={this.hidden}>
<h1>Welcome to mono-jsx!</h1>
</hidden>
</div>
)
}If you need if-else logic in JSX, use the <switch> element instead:
function App(this: FC<{ ok: boolean }>) {
return (
<div>
<switch value={this.ok}>
<span slot="true">True</span>
<span slot="false">False</span>
</switch>
</div>
)
}The <switch> element renders different content based on the value prop. Elements with matching slot props are displayed when their value matches, otherwise default slots are shown. Like <show>, you can use signals to control the value on the client side.
function App(this: FC<{ lang: "en" | "zh" | "π" }>) {
this.lang = "en";
return (
<div>
<switch value={this.lang}>
<h1 slot="en">Hello, world!</h1>
<h1 slot="zh">δ½ ε₯½οΌδΈηοΌ</h1>
<h1 slot="π">βπβοΈ</h1>
</switch>
<p>
<button onClick={() => this.lang = "en"}>English</button>
<button onClick={() => this.lang = "zh"}>δΈζ</button>
<button onClick={() => this.lang = "π"}>π</button>
</p>
</div>
)
}You can use the $value prop to bind a signal to the value of a form input element. The $value prop provides two-way data binding, which means that when the input value changes, the signal is updated, and when the signal changes, the input value is updated.
function App(this: FC<{ value: string }>) {
this.value = "Welcome to mono-jsx";
this.effect(() => {
console.log("value changed:", this.value);
});
// return <input value={this.value} oninput={e => this.value = e.target.value} />;
return <input $value={this.value} />;
}You can also use the $checked prop to bind a signal to the checked state of a checkbox or radio input.
function App(this: FC<{ checked: boolean }>) {
this.effect(() => {
console.log("checked changed:", this.checked);
});
// return <input type="checkbox" checked={this.checked} onchange={e => this.checked = e.target.checked} />;
return <input type="checkbox" $checked={this.checked} />;
}1. Arrow functions are non-stateful components.
// β Won't work - uses `this` in a non-stateful component
const App = () => {
this.count = 0;
return (
<div>
<span>{this.count}</span>
<button onClick={() => this.count++}>+</button>
</div>
)
};
// β
Works correctly
function App(this: FC) {
this.count = 0;
return (
<div>
<span>{this.count}</span>
<button onClick={() => this.count++}>+</button>
</div>
)
}2. Signals would not be computed automatically outside of the this.computed method.
// β Won't work - updates of a signal won't refresh the view
function App(this: FC<{ message: string }>) {
this.message = "Welcome to mono-jsx";
return (
<div>
<h1>{this.message + "!"}</h1>
<button onClick={() => this.message = "Clicked"}>
Click Me
</button>
</div>
)
}
// β
Works correctly
function App(this: FC) {
this.message = "Welcome to mono-jsx";
return (
<div>
<h1>{this.computed(() => this.message + "!")}</h1>
<button onClick={() => this.message = "Clicked"}>
Click Me
</button>
</div>
)
}3. this in nested functions in a component function would not be bound to the component. You can use an arrow function that automatically binds this to the component.
function App(this: FC<{ count: number }>) {
function increment() {
this.count++; // β `this` is not bound to the component.
}
return (
<div>
<span>{this.count}</span>
<button onClick={increment}>{this.count}</button>
</div>
)
}
function App(this: FC) {
const increment = () => {
this.count++; // β
`this` is bound to the component.
}
return (
<div>
<span>{this.count}</span>
<button onClick={increment}>{this.count}</button>
</div>
)
}mono-jsx-dom binds a scoped signals object to this in your component functions. This allows you to access signals, context, and request information directly in your components.
The this object has the following built-in properties:
atom(initValue): Creates an atom signal.store(initValue): Creates a signal store.init(initValue): Initializes the signals.refs: A map of refs defined in the component.computed(fn): A method to create a computed signal.$(fn): A shortcut forcomputed(fn).effect(fn): A method to create side effects.
type FC<Signals = {}, Refs = {}> = {
atom<T>(initValue: T): Atom<T>;
store<T extends Record<string, unknown>>(initValue: T): T;
init(initValue: Signals): void;
refs: Refs;
computed<T = unknown>(fn: () => T): T;
$: FC["computed"]; // A shortcut for `FC.computed`.
effect(fn: () => void | (() => void)): void;
} & Signals;See the Using Signals section for more details on how to use signals in your components.
You can use this.refs to access refs in your components. Refs are defined using the ref prop in JSX, and they allow you to access DOM elements directly. The refs object is a map of ref names to DOM elements.
function App(this: WithRefs<FC, { input?: HTMLInputElement }>) {
this.effect(() => {
this.refs.input?.addEventListener("input", (evt) => {
console.log("Input changed:", evt.target.value);
});
});
return (
<div>
<input ref={this.refs.input} type="text" />
<button onClick={() => this.refs.input?.focus()}>Focus</button>
</div>
)
}MIT
