Skip to content

Instantly share code, notes, and snippets.

@J-Cake
Created September 12, 2025 11:20
Show Gist options
  • Save J-Cake/7bb5406388f924f87015c498fc718186 to your computer and use it in GitHub Desktop.
Save J-Cake/7bb5406388f924f87015c498fc718186 to your computer and use it in GitHub Desktop.
Tiny reactive JSX renderer

An absolutely tiny JSX runtime.

Drop this into your project and import, and you have a simple JSX runtime. It's very bare-bones but does have a reactive mechanism.

import * as dom from './jsx.js';
import {JSX} from './jsx.js';

dom.renderer(document.querySelector("#root")!, <div>
  <Counter />
</div>);

function Counter(props: JSX.Props<{ children: JSX.Children }>) {
  const count = dom.reactive(0);
  
  return <div>
    <button on:click={e => count.set(count + 1)}>{"Increment"}</button>
    
    <p>
      <b>The current count is: </b>
      <span>{count}</span>
    </p>
  </div>;
}
type DOMNode = Node;
const element = Symbol('Element');
export namespace JSX {
export interface Element {
[element]: typeof element
render(core: {}): DOMNode;
}
export type IntrinsicElements = { [ElementName in string]: Partial<StandardProps> };
export type StandardProps = {
class: string | string[],
key: keyof any,
} & { [Listener in `on:${string}`]: EventHandler<Listener> };
export type Props<P extends object> = Partial<StandardProps> & P;
export type PropsTyped<T, P extends object> = Props<P> & { children: T | T[] };
type EventHandler<Listener extends `on:${string}`> = Listener extends `on:${infer EventName}`
? EventName extends keyof HTMLElementEventMap ? ((e: HTMLElementEventMap[EventName]) => void)
: ((e: Event) => void)
: never;
export type Node = <Props extends StandardProps>(props: Props) => JSX.Element;
type ChildSingular = JSX.Element | Node | Reactor<any> | string | number | null | undefined;
export type Child = ChildSingular | Iterable<ChildSingular>;
export type Children = Child | Child[];
}
const reactive = Symbol("reactive");
export type Element = JSX.Element;
export type IntrinsicElements = JSX.IntrinsicElements;
const isEventListener = (str: string): str is `on:${string}` => str.startsWith("on:");
interface ReactiveState {
instance: number,
ctx: Core[]
active: Core,
}
export interface Core {
id: number,
values: Reactor<any>[],
}
declare global {
interface Window {
[reactive]: ReactiveState;
}
}
export class Reactor<T> {
#value: T;
#watches: ((value: T) => void )[] = [];
constructor(value: T) {
this.#value = value;
}
get(): T {
return this.#value;
}
set(value: T): void {
this.#value = value;
this.#notify();
}
update(value: (prev: T) => T): void {
this.set(value(this.#value));
}
#notify(): void {
this.#watches.forEach(i => i(this.#value));
}
onUpdate(dependent: (value: T) => void): Reactor<T> {
this.#watches.push(dependent);
return this;
}
map<R>(predicate: (i: T) => R): Reactor<R> {
const reactor = new Reactor(predicate(this.get()));
this.onUpdate(newValue => reactor.set(predicate(newValue)));
return reactor;
}
}
export function value<T>(value: T): Reactor<T> {
return new Reactor(value);
}
function newReactiveState(): ReactiveState {
return Object.defineProperty({
instance: 0,
ctx: [] as Core[],
}, 'active', {
get(): Core {
return window[reactive].ctx[window[reactive].instance];
}
}) as ReactiveState;
}
export function renderer(root: HTMLElement, app: JSX.Element): { unmount: () => void } {
const id = ++(window[reactive] ??= newReactiveState()).instance;
let core: Core;
window[reactive].ctx.push(core = {
id,
values: []
});
root.append(app.render(core));
return {
unmount: () => root
}
}
type DeReactor<P extends object> = {
[Prop in keyof P]: P[Prop] extends Reactor<infer O> ? O : P[Prop]
};
function* renderChildSingular(child: JSX.Child): Generator<Node> {
const core = window[reactive].active;
if (!child)
return;
if (typeof child === 'string' || typeof child == 'number')
yield document.createTextNode(child.toString());
else if (Symbol.iterator in child)
for (const subchild of child)
yield* renderChildSingular(subchild);
else if (child instanceof Node)
yield child;
else if (child instanceof Reactor) {
const parent = document.createDocumentFragment();
renderChild(parent, child);
yield parent;
}
else if (element in child)
yield child.render(core);
else
yield document.createTextNode(String(child));
}
function renderChild(parent: ParentNode, child: JSX.Child) {
if (child instanceof Reactor) {
let children = Array.from(renderChildSingular(child.get()));
child.onUpdate(value => {
for (const child of children) parent.removeChild(child);
parent.append(...children = Array.from(renderChildSingular(value)));
});
parent.append(...children);
} else
parent.append(...renderChildSingular(child));
}
export function jsx(el: string | JSX.Node, props: DeReactor<JSX.StandardProps & { children?: JSX.Children }>): JSX.Element {
return {
[element]: element,
render(core: {}) {
if (typeof el == 'string') {
const node = document
.createElement(el);
for (const [listener, handler] of Object.entries(props))
if (isEventListener(listener))
node.addEventListener(listener.slice(3) as unknown as any, handler as unknown as any);
if (props.children)
for (const child of [props.children].flat())
renderChild(node, child);
return node;
} else
return el(props)
.render(core);
}
}
}
export const jsxs = jsx;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment