npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

goblinjs

v0.6.1

Published

A React-flavored UI framework with NO virtual DOM. Built by goblins, for goblins.

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@latest

It 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 dev

Other package managers work too and are auto-detected:

pnpm create goblin my-app
yarn create goblin my-app
bun create goblin my-app

You 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 goblinjs

Quick 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 .jsh returns 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; :param captures, 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> and navigate("/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 via useParams().
  • 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 to element={Home} — same instance, same state across parent re-renders, no extra wrapper. Without the plugin it falls back to eager and console.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 ref

Holes 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 / Rollupgoblinjs/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" },
};

esbuildgoblinjs/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 only

On 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-jsx parse your JSX during the build and never reach your bundle — your runtime stays the same size. They're declared as optional peer deps, so npm install goblinjs itself 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 html API 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) → a useStore hook. 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 is Object.is. When a selector returns a fresh object/array (e.g. picking several fields), pass the shallow helper 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.)

  • fallback is a node, or a function (error, reset) => node. reset() clears the error and re-runs the children — handy for a retry button. Omit fallback and 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>-style createContext/useContext — use a create store 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