goblinjs
v0.6.1
Published
A React-flavored UI framework with NO virtual DOM. Built by goblins, for goblins.
Maintainers
Readme
goblinjs 👺
A React-flavored UI framework with no virtual DOM. Hooks, components, and routing — built by goblins, for goblins.
It's a parody. It also works.
The principle
h() builds real DOM nodes immediately. There is no virtual tree kept
around. Every component is bracketed in the live DOM by two comment markers:
<!--g--> ...your component's nodes... <!--/g-->When a component's state changes, the goblin re-runs only that component's
function, builds the fresh nodes, then morphs the live DOM onto them —
reusing same-tag nodes in place, patching only what changed, and moving or
inserting the rest. It diffs freshly-built real nodes against the live ones
at commit time and throws the surplus away — still no retained shadow tree.
That's why a focused <input> keeps its focus across a re-render. Updates are
batched per microtask.
Because output is plain DOM, any framework-agnostic npm module works (axios, zod, nanoid, date-fns) and any CSS framework works (Tailwind, Bootstrap, Bulma). React component libraries (MUI, Chakra, shadcn) do not — they need React's runtime.
Create a new app (the starter)
The fastest way in — scaffold a ready-to-run goblin app with Vite, Tailwind, the
JSX→html hoist compiler, and a polished showcase already wired up:
npm create goblin@latestIt asks for a project name (default goblin-app), then prints the next steps.
Prefer one line? Pass the name (and --install to install deps for you):
npm create goblin@latest my-app -- --install
cd my-app
npm run devOther package managers work too and are auto-detected:
pnpm create goblin my-app
yarn create goblin my-app
bun create goblin my-appYou get a src/main.jsx entry, npm run dev (Vite dev server), npm run build,
and npm run preview. Edit src/main.jsx, save, and the page hot-reloads.
Just want the library in an existing project? Skip the starter and install goblin directly:
npm install goblinjsQuick start (JSX)
Goblin compiles JSX two ways — pick one.
Automatic runtime (recommended, no manual import):
// tsconfig.json / jsconfig.json
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "goblinjs" } }import { mount, useState } from "goblinjs";
function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN((x) => x + 1)}>count: {n}</button>;
}
mount(Counter, document.getElementById("root"));Classic runtime (import the factory yourself):
{ "compilerOptions": { "jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment" } }import { h, Fragment, mount, useState } from "goblinjs";With esbuild: --jsx=automatic --jsx-import-source=goblinjs (automatic) or
--jsx-factory=h --jsx-fragment=Fragment (classic). Vite: set
esbuild.jsxImportSource to goblinjs.
Prefer no build at all? Skip JSX and call
h("div", { class: "x" }, ...)directly in plain.js—hreturns real DOM.
Hooks
const [value, setValue] = useState(initial); // initial can be a lazy () => ...
const ref = useRef(initialValue); // { current } — stable across renders
useEffect(() => { // runs after commit
// ...side effect...
return () => {/* cleanup on dep-change / unmount */};
}, [deps]); // [] = once, omit = every render
const memo = useMemo(() => compute(a, b), [a, b]);
const cb = useCallback(fn, [deps]);
const [state, dispatch] = useReducer(reducer, initialArg, init); // init optional (lazy)useReducer is useState's heavier cousin: state transitions go through a
reducer(state, action) and you dispatch(action) to advance it. The dispatch
identity is stable across renders, so it's safe to pass to memoized children or
list in effect deps. Pass a third init arg for lazy initialization
(init(initialArg) runs once).
Same mental model as React: hooks run in call order, so keep that order stable (no hooks inside conditionals).
Refs to DOM nodes
Pass a ref — a useRef/createRef box or a callback — to grab the real DOM
node. Goblin wires it at commit (on the node that actually goes live, never a
throwaway) and tears it down like React: a callback ref is called with the node
on mount and with null on unmount; an object ref gets .current set, then
reset to null.
function Field() {
const input = useRef(null);
useEffect(() => input.current.focus(), []); // .current is the live <input>
return <input ref={input} />;
}
// callback ref: the node on mount, null on unmount
<div ref={(el) => (el ? observer.observe(el) : observer.disconnect())} />Routing
Same shapes as React Router. HashRouter (#/path) needs no server config
and works on static hosting and file://. BrowserRouter gives clean
/path URLs via the History API. Hash is the default-friendly one; Browser is
the one that looks like production.
import { BrowserRouter, Routes, Route, Link, useParams, navigate } from "goblinjs/router";
function App() {
return (
<BrowserRouter>{() => (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/users/:id" element={<User />} />
<Route path="*" element={<NotFound />} />
</Routes>
)}</BrowserRouter>
);
}
function User() {
const { id } = useParams(); // matched route params, React v6-style
return <h1>user {id}</h1>;
}<Routes>/<Route path element>— first match wins;:paramcaptures,path="*"is the catch-all.useParams()reads the matched params.<Link to="/x">navigates with no reload (history mode intercepts the click; cmd/middle-clicks still open a new tab).<Link replace>andnavigate("/x", { replace: true })replace instead of push.navigate("/x")for programmatic moves;useRoute()returns the current path and re-renders on change. Browser Back/Forward just work.
element is a component, not an eager <Element/>
Goblin's h() is eager — it builds real DOM the instant it runs — so a bare
element={<Home/>} would build every route's component, matched or not. Two
ways to keep it lazy:
element={Home}— pass the component reference. Works everywhere, no build magic. Params arrive viauseParams().element={<Home/>}— the literal React form, only when you build through goblin's compiler plugin (goblinHoist(), below). The compiler wraps the JSX in a tagged lazy thunk at build time, so it behaves identically toelement={Home}— same instance, same state across parent re-renders, no extra wrapper. Without the plugin it falls back to eager andconsole.warns.
Provider form, or a routes array
<BrowserRouter>/<HashRouter> either wrap your app as a provider — a
render-prop child {() => <App/>} (required because eager h() means the child
must be deferred until the mode flag is set, the same rule as ErrorBoundary) —
or take a routes={[...]} array directly. The data-array form also works on the
lower-level Router with zero JSX:
<Router
routes={[
{ path: "/", component: Home },
{ path: "/users/:id", component: User }, // props.params.id
]}
fallback={NotFound}
/>Clean URLs need a server fallback
BrowserRouter's /users/2 is a real path the browser sends to your server on a
refresh or a pasted link. Your server (or static host) must serve index.html
for unknown paths, so the bundle boots and routes client-side — the standard SPA
fallback every history router needs. HashRouter sidesteps it entirely
(nothing after # is ever sent to the server), which is why it's the zero-config
default.
Keyed lists
Give each item in a dynamic list a stable key and a component's state follows
its key — not its position — when the list reorders, or grows/shrinks in the
middle:
{todos.map((t) => <TodoRow key={t.id} todo={t} />)}Reorder the list and TodoRow's internal state (and effects) travels with its
key instead of staying pinned to the slot. The morph matches each child block to
its instance by key, so DOM nodes move as units and reused nodes keep their
identity — there's still no virtual tree. Without a key, items fall back to
positional matching (the Nth item reuses the Nth instance), so append/pop is
safe but mid-list reordering can mismatch. Keys must be unique among siblings;
duplicates log a warning.
Skipping work with memo
Wrap a component in memo and a parent re-render that passes shallow-equal
props skips it entirely — the goblin keeps the child's existing DOM and state
instead of rebuilding and morphing its subtree:
import { memo } from "goblinjs";
const Row = memo(function Row({ todo }) {
return <li>{todo.title}</li>;
});Now when the parent re-renders for an unrelated reason, Row only re-runs if
todo (or any other prop) actually changed. The child can still re-render from
its own state at any time, and a bailed child still moves correctly when a
keyed list reorders. Pass a custom comparator for full control:
const Row = memo(RowImpl, (prev, next) => prev.todo.id === next.todo.id);areEqual(prevProps, nextProps) returns true to skip the render. Call memo()
once at module scope (like React) — wrapping inside a render makes a new type
each time and defeats the bailout. Note: like React, passing JSX children
usually defeats the default bailout, since children are freshly built each render
and won't be shallow-equal.
Static hoisting with html templates (zero build step)
By default a re-render rebuilds the whole subtree before morphing it. For a
component that's mostly static markup with a few moving parts, that's wasted
work. The html tagged-template skips it: the static markup is parsed once
and cached, and on every re-render only the dynamic holes are touched — the
static DOM is never rebuilt, walked, or even diffed. It's a mini lit-html, and
it needs no build step — it runs in plain .js.
import { html, mount, useState } from "goblinjs";
function SignupCard() {
const [email, setEmail] = useState("");
return html`
<div class="card">
<h2 class="title">Join the horde</h2> <!-- never touched again -->
<p class="blurb">All goblins welcome.</p> <!-- never touched again -->
<input class="field" .value=${email} @input=${(e) => setEmail(e.target.value)} />
<span class="count">${email.length} chars</span> <!-- only this updates -->
</div>`;
}
mount(SignupCard, document.getElementById("root"));Re-rendering that card on every keystroke allocates zero elements — it just
writes the two holes. The static shell keeps its node identity, so a focused
<input> keeps focus and caret automatically.
Hole kinds
${child} // text, a DOM node, a component, or a list (see below)
.prop=${value} // assigned to el[prop] — e.g. .value=${v}, .checked=${on}
@event=${fn} // event listener (on-property); onInput=${fn} also works
attr=${value} // a whole attribute value — class=${cls}
attr="a ${x} b" // stitch static text + multiple holes into one attribute
ref=${refOrCallback} // same refs-at-commit lifecycle as JSX refHoles are first-class goblin children
A child hole isn't limited to text — drop a component, and it gets a real
instance with its own state, effects, and memo bailout, reconciled through the
exact same machinery as JSX children. A keyed list in a hole reconciles by
key (state follows the key across a reorder), and refs fire at commit and tear
down on unmount — all without rebuilding the template's static shell:
function Board() {
const [cols, setCols] = useState(["todo", "doing", "done"]);
return html`
<section class="board">
<h1 class="board-title">Sprint</h1> <!-- static, hoisted -->
<div class="cols">
${cols.map((name) => h(Column, { key: name, name }))}
</div>
</section>`;
}html vs JSX
They interoperate — ${h(Comp)} in a hole and html returned from a component
both just work. Reach for html when a component is static-heavy and re-renders
hot (forms, cards, dashboards) and you want the zero-allocation update, or when
you simply don't want a build step. Hand-written html asks you to mark
.value=/@input= yourself, where JSX infers it — but if you'd rather just write
plain JSX and get the same hoisting for free, the compiler below does exactly
that.
Automatic static hoisting (the compiler)
Don't want to hand-write html? An optional build-time compiler rewrites your
plain JSX into the hoisted html form for you — so every component gets the
zero-rebuild update path with no template ceremony. It plugs into the build
you already have (JSX always needs some build step):
Vite / Rollup — goblinjs/vite-plugin:
// vite.config.js
import { goblinHoist } from "goblinjs/vite-plugin";
export default {
plugins: [goblinHoist()], // runs before Vite's JSX pass
esbuild: { jsx: "automatic", jsxImportSource: "goblinjs" },
};esbuild — goblinjs/esbuild-plugin:
// build.mjs
import esbuild from "esbuild";
import { goblinHoist } from "goblinjs/esbuild-plugin";
await esbuild.build({
entryPoints: ["src/main.jsx"],
bundle: true,
jsx: "automatic",
jsxImportSource: "goblinjs",
plugins: [goblinHoist()],
});npm install -D acorn acorn-jsx # the compiler's only deps — build-time onlyOn another bundler (webpack, Parcel, a Babel pipeline)? The core is just a
string→string function — import { compile } from "goblinjs/compiler" and call it
from that tool's transform hook; the two plugins above are ~15-line adapters around
it.
Now write ordinary JSX and the static shell is hoisted automatically:
function SignupCard() {
const [email, setEmail] = useState("");
return (
<div class="card">
<h2 class="title">Join the horde</h2> {/* baked once, never rebuilt */}
<p class="blurb">All goblins welcome.</p>
<input class="field" value={email} onInput={(e) => setEmail(e.target.value)} />
<span class="count">{email.length} chars</span> {/* only this updates */}
</div>
);
}That compiles to an html template behind your back: static attributes and text
bake into the markup, dynamic props (value, onInput, className, style, …)
collapse into one prop-hole applied through goblin's normal prop logic, and
{expr} children become holes. Component elements and JSX inside expressions
({cond ? … : …}, arr.map(...)) are left as-is — esbuild compiles them to h(),
and the template's child holes reconcile them (components, keyed lists, refs) just
like hand-written html. The result is byte-identical behavior to plain JSX, but
with the static rebuild skipped.
- Build-time only.
acorn/acorn-jsxparse your JSX during the build and never reach your bundle — your runtime stays the same size. They're declared as optional peer deps, sonpm install goblinjsitself pulls no dependencies. - Opt-in and reversible. Drop the plugin and your JSX falls back to the normal
h()path — nothing else changes. - One behavior note: because hoisting reuses static nodes instead of rebuilding them each render, a CSS mount-animation that relied on goblin re-creating a node every render won't replay. Key a component (or toggle a class) if you need the animation to re-fire — the same tradeoff React has with keyed remounts.
Prefer zero build? The hand-written
htmlAPI above gives you the exact same hoisting with no compiler. The plugin just removes the typing.
Global state (the goblin's hoard)
Need state shared across the tree without threading props? create a store — in
the spirit of zustand, but mini. No provider, no context, no wrapping your app.
The store lives outside the render tree, so a component re-renders only
when the slice it selects actually changes:
import { create } from "goblinjs";
const useBears = create((set, get) => ({
count: 0,
add: () => set((s) => ({ count: s.count + 1 })),
reset: () => set({ count: 0 }),
}));
function Counter() {
const count = useBears((s) => s.count); // re-renders only when count changes
const add = useBears((s) => s.add);
return <button onClick={add}>bears: {count}</button>;
}create(initializer)→ auseStorehook. The initializer gets(set, get); return your state plus actions.set(partial)shallow-merges (like React);set(partial, true)replaces;set((prev) => partial)for updater form.useStore(selector?, isEqual?)subscribes the component to the selected slice. Default equality isObject.is. When a selector returns a fresh object/array (e.g. picking several fields), pass theshallowhelper so it re-renders only when one of those values actually changes:import { create, shallow } from "goblinjs"; const { a, b } = useStore((s) => ({ a: s.a, b: s.b }), shallow);Outside components, the API hangs off the hook itself:
useStore.getState(),useStore.setState(...),useStore.subscribe(fn)— handy in event handlers, effects, tests, or one store reacting to another.
Subscriptions are torn down automatically when a component unmounts. Under the
hood it's a standard useSyncExternalStore(subscribe, getSnapshot) — exported too,
if you want to wire up some other external source.
Error handling
When a component throws, you usually don't want the whole page to die. Wrap a
subtree in an ErrorBoundary and a throw is caught and swapped for a fallback —
the rest of the app keeps running.
import { ErrorBoundary } from "goblinjs";
<ErrorBoundary fallback={(err, reset) => (
<div>
<p>The goblin tripped: {String(err.message)}</p>
<button onClick={reset}>Try again</button>
</div>
)}>
{() => <RiskyThing />}
</ErrorBoundary>Note the function child ({() => <RiskyThing />}). Goblin's h() is eager —
it builds real DOM as it runs — so JSX written directly between the boundary's
tags is built before the boundary can wrap it in a try. Deferring the
children behind a function lets the boundary actually catch the throw. (React
fakes the same effect with lazy elements; the goblin would rather hand you a
function than grow a virtual DOM.)
fallbackis a node, or a function(error, reset) => node.reset()clears the error and re-runs the children — handy for a retry button. Omitfallbackand a small built-in one renders.onError(error, reset)is an optional side-effect hook for logging/telemetry.- A boundary catches errors thrown during render, during a descendant's own later re-render, and inside effects — but not in event handlers or async callbacks (those don't run during a render), exactly like React.
Anything that throws with no boundary above it surfaces a goblin dev error
overlay (a full-screen red screen with the message and stack) instead of a
silent blank page. Toggle it with setDevOverlay(false) (e.g. in production, where
you'd rather rely on your own boundaries), or remove a showing one with
dismissErrorOverlay().
Honest limitations
- JSX children render in the owner's hook context (a simplification of
React's reconciliation). This is also why there's no
<Provider>-stylecreateContext/useContext— use acreatestore for shared state instead.
API
h / createElement, Fragment, mount,
useState, useEffect, useRef, useMemo, useCallback, useReducer,
useSyncExternalStore, createRef, memo, create, shallow,
html, isTemplateResult,
ErrorBoundary, setDevOverlay, dismissErrorOverlay,
and from goblinjs/router: BrowserRouter, HashRouter, Routes, Route,
Router, Link, navigate, useRoute, useParams, getPath, matchRoute.
The store also has its own entry: goblinjs/store.
Build-time (optional): goblinjs/vite-plugin and goblinjs/esbuild-plugin both
export goblinHoist(), and goblinjs/compiler exports
compile(source, { importSource? }) if you want to run the JSX→html transform
from any other bundler yourself.
License
MIT
