Skip to content

ije/mono-jsx-dom

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

58 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ”₯ mono-jsx-dom

mono-jsx-dom

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

Getting Started

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 init

Usage

mono-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.

Using JSX

mono-jsx-dom uses JSX to describe the user interface, similar to React but with key differences.

Using Standard HTML Property Names

mono-jsx-dom adopts standard HTML property names, avoiding React's custom naming conventions:

  • className β†’ class
  • htmlFor β†’ for
  • onChange β†’ onInput

Composition with class

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 },
  ]}
/>;

Using Pseudo-Classes and Media Queries in style

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>;

Using <slot> Element

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>
  )
}

Using html Tag Function

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.

Event Handlers

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>
  )
}

Async Components

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 />);

Error Handling

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.

Using Signals

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.

Using Component Signals

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>
  )
}

Atom signals

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;
}

Signal stores

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>
  );
}

Using Computed Signals

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.

Using Effect

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>
  )
}

Using <show> Element with Signals

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>
  )
}

Using <switch> Element with Signals

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>
  )
}

Form Input Two-way Binding

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} />;
}

Limitations of Signals

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>
  )
}

Using this in Components

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 for computed(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;

Using Signals

See the Using Signals section for more details on how to use signals in your components.

Using Refs

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>
  )
}

License

MIT

About

A JSX runtime for browsers.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors