After a long hardening pass, SibuJS 4.0.1 is now available.
I started SibuJS around a fairly simple question: if the browser already gives us a DOM, how much framework machinery do we really need between our state and that DOM?
SibuJS is my answer. It is a function-based frontend framework where a state change updates the exact part of the DOM that depends on it. There is no Virtual DOM, no component-tree reconciliation, no hooks, and no compiler required to make the reactivity work.
That does not mean “write everything manually with document.createElement.” SibuJS provides declarative tag functions, signals, derived state, effects, keyed lists, lifecycle management, async components, error boundaries and more—but the result is still real DOM.
The core principles are:
- Fine-grained reactivity
- Direct DOM rendering
- Plain JavaScript functions as components
- No required JSX or compilation step
- Zero runtime dependencies
- No component re-render cycle
Let’s look at what that means in practice.
Installation
Install SibuJS from npm:
npm install sibujs
Then import only what you need:
import { div, h1, button, signal, mount } from "sibujs";
SibuJS also provides a CDN bundle for projects that do not use a bundler.
A small counter
Here is the usual counter example:
import { button, div, h1, mount, signal } from "sibujs";
function Counter() {
const [count, setCount] = signal(0);
return div("counter", [
h1(() => `Count: ${count()}`),
button(
{
type: "button",
on: {
click: () => setCount(count() + 1),
},
},
"Increment",
),
]);
}
mount(Counter, document.getElementById("app"));
signal(0) returns a getter and a setter. Reading count() inside the h1 binding creates a dependency. When setCount() changes the value, SibuJS updates that heading directly.
The Counter component itself is not repeatedly executed and compared against an old component tree. The reactive binding owns the update.
Derived state
Most applications need values calculated from other values. That is what derived() is for:
import { derived, div, signal } from "sibujs";
function PriceSummary() {
const [price] = signal(40);
const [quantity] = signal(2);
const total = derived(() => price() * quantity());
return div("summary", [
div(() => `Price: $${price()}`),
div(() => `Quantity: ${quantity()}`),
div(() => `Total: $${total()}`),
]);
}
A derived value is memoized and tracks its dependencies automatically. It is recalculated only when one of those dependencies changes and the value is needed.
Derived signals can also use a custom equality function. If the recalculated result is considered equal to the previous one, SibuJS can prevent unnecessary downstream work.
Effects and cleanup
An effect is useful when reactive state needs to interact with something outside the rendered DOM: analytics, browser APIs, storage, subscriptions or timers.
import { effect, signal } from "sibujs";
const [userId, setUserId] = signal("fran");
const stop = effect((onCleanup) => {
const controller = new AbortController();
fetch(`/api/users/${userId()}`, {
signal: controller.signal,
});
onCleanup(() => controller.abort());
});
// Later, if this effect is no longer needed:
stop();
The cleanup runs before the effect executes again and when the effect is disposed. This explicit ownership becomes important in long-running applications, where forgotten listeners or unfinished async work can otherwise accumulate.
Batching related updates
SibuJS updates synchronously by default. When several state changes belong to one operation, batch() groups their notifications into a single flush:
import { batch, signal } from "sibujs";
const [firstName, setFirstName] = signal("Bruce");
const [lastName, setLastName] = signal("Dickinson");
batch(() => {
setFirstName("Adrian");
setLastName("Smith");
});
Consumers observing both signals see the completed update instead of two intermediate states.
Conditional UI
SibuJS includes reactive control-flow primitives. when() switches between branches and disposes the branch that is no longer active:
import { button, div, mount, signal, when } from "sibujs";
function AccountPanel() {
const [signedIn, setSignedIn] = signal(false);
return div([
button(
{ on: { click: () => setSignedIn(!signedIn()) } },
() => (signedIn() ? "Sign out" : "Sign in"),
),
when(
signedIn,
() => div("account", "Welcome back."),
() => div("guest", "You are browsing as a guest."),
),
]);
}
mount(AccountPanel, document.getElementById("app"));
There is also show() for cases where an element should remain mounted while its visibility changes.
Keyed lists without a Virtual DOM
List rendering is where “direct DOM” implementations can become deceptively complicated. SibuJS provides each() so applications do not have to rebuild a list whenever its order changes.
import { button, div, each, li, mount, signal, ul } from "sibujs";
function PeopleList() {
const [people, setPeople] = signal([
{ id: 1, name: "Steve Harris" },
{ id: 2, name: "Dave Murray" },
{ id: 3, name: "Adrian Smith" },
]);
const rows = each(
people,
(person, index) =>
li(() => `${index() + 1}. ${person().name}`),
{ key: (person) => person.id },
);
return div([
button(
{ on: { click: () => setPeople([...people()].reverse()) } },
"Reverse order",
),
ul(rows),
]);
}
mount(PeopleList, document.getElementById("app"));
Each row is associated with a stable key. When the array is reordered, SibuJS preserves existing row identity and minimizes DOM moves using longest-increasing-subsequence reconciliation. The item and index passed to the row are reactive getters, so an existing row can receive fresh data without being recreated.
Components are just functions
SibuJS components do not require a base class or a special component object:
import { article, h2, p } from "sibujs";
function ArticleCard({ title, excerpt }) {
return article("card", [
h2(title),
p(excerpt),
]);
}
You can compose them like any other function:
import { div } from "sibujs";
function ArticleGrid({ articles }) {
return div(
"article-grid",
articles.map((article) => ArticleCard(article)),
);
}
You are also free to use native DOM APIs whenever they are the clearest solution. SibuJS elements are normal HTMLElement objects, not framework wrappers.
Need ready-made components? Meet SibuJS UI
SibuJS itself provides the rendering and reactivity foundation, but most applications also need buttons, dialogs, menus, form controls and other reusable interface elements. For that, there is SibuJS UI, the first-party component library for the framework.
SibuJS UI 1.5.1 currently includes 56 components covering layout, forms, feedback, navigation and data display. The components are built with SibuJS signals and direct DOM rendering, styled for Tailwind CSS v4, fully typed, themeable and distributed as tree-shakeable ESM and CommonJS modules.
Install it alongside the framework:
npm install sibujs sibujs-ui
Add the base styles and a theme to your stylesheet:
@import "tailwindcss";
@import "tw-animate-css";
@import "sibujs-ui/themes/base.css";
@import "sibujs-ui/themes/default.css";
Then use its components like ordinary SibuJS functions:
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from "sibujs-ui";
import { mount } from "sibujs";
function WelcomeCard() {
return Card([
CardHeader([
CardTitle("Welcome to SibuJS"),
]),
CardContent([
Button(
{
variant: "default",
on: {
click: () => alert("Hello from SibuJS UI!"),
},
},
"Get started",
),
]),
]);
}
mount(WelcomeCard, document.getElementById("app"));
The library includes components such as Accordion, Card, Sidebar, Table, Tabs, Combobox, Form, Select, Dialog, Drawer, Toast, Tooltip, DropdownMenu, NavigationMenu, Calendar and Chart.
Stateful components can receive reactive getters directly. For example, a SibuJS signal can control a dialog without adapters or framework-specific wrappers:
import { Button, Dialog, DialogContent } from "sibujs-ui";
import { div, signal } from "sibujs";
function ControlledDialog() {
const [open, setOpen] = signal(false);
return div([
Button(
{ on: { click: () => setOpen(true) } },
"Open dialog",
),
Dialog({ open, onOpenChange: setOpen }, [
DialogContent("This dialog is controlled by a SibuJS signal."),
]),
]);
}
SibuJS UI is optional: you can use the framework with your own CSS, native elements or another design system. It exists for projects that want a ready-made component layer while preserving the same signal-driven, zero-VDOM model as the core framework.
Islands: interactivity without taking over the page
SibuJS can render a complete application, but it does not have to own the whole page. Its islands API lets you add reactivity only to the parts that need it while the rest remains ordinary server-rendered HTML.
Imagine that your backend sends this markup:
<article>
<h1>My server-rendered article</h1>
<p>Most of this page is static HTML.</p>
<div data-sibu-island="counter">
<output data-ref="count">0</output>
<button data-ref="increment">Increment</button>
</div>
</article>
Instead of rebuilding the article on the client, we can register just the interactive counter:
import { mountIslands, registerIsland, signal } from "sibujs";
registerIsland("counter", (ctx) => {
const [count, setCount] = signal(0);
ctx.text("@count", () => count());
ctx.on("@increment", "click", () => {
setCount(count() + 1);
});
});
const disposeIslands = mountIslands();
The @count and @increment references resolve elements marked with matching data-ref attributes inside that island. SibuJS attaches the text binding and event listener to the existing DOM rather than recreating the surrounding page.
This is useful for HTML produced by almost any backend or static-site generator: PHP, Laravel, Rails, Django, Go templates, Eleventy, Hugo or a CMS. You can adopt SibuJS one widget at a time without migrating the entire site into a client-rendered application.
Activate an island only when it is needed
Not every interactive element needs to start immediately. The data-sibu-load attribute controls when an island activates:
<!-- Activates on the next microtask -->
<div data-sibu-island="account" data-sibu-load="load">
<!-- ... -->
</div>
<!-- Activates when it enters the viewport -->
<div data-sibu-island="chart" data-sibu-load="visible">
<!-- ... -->
</div>
<!-- Activates after the user interacts with it -->
<div data-sibu-island="comments" data-sibu-load="interaction">
<!-- ... -->
</div>
SibuJS supports five activation strategies:
-
load— activate immediately; this is the default -
idle— wait until the browser is idle -
visible— activate when the island enters the viewport -
interaction— wait for pointer, focus, keyboard or touch interaction -
media— activate when a media query matches
Code can be deferred too. With lazyIsland(), the module is fetched only when the island activates:
import { lazyIsland, mountIslands, registerIsland } from "sibujs";
registerIsland(
"chart",
lazyIsland(() => import("./islands/chart.js")),
);
mountIslands();
An off-screen chart that the user never reaches does not need to download or execute its JavaScript.
mountIslands() returns a cleanup function that cancels pending activation and disposes mounted islands. Already active islands are not wired twice, and a failure in one island does not prevent its siblings from activating.
The same API also works through the CDN build, so islands can be added to an HTML-first site without npm, JSX or a compilation step.
What changed in 4.0?
Version 4 was primarily a hardening release rather than an API redesign. Much of the work focused on the cases that tend to appear only after an application becomes complex:
- Async ownership and stale-result prevention
- Router navigation cancellation and supersession
- Query and mutation lifecycle handling
- Disposal of reactive DOM subtrees
- SSR request isolation
- Hydration and island activation
- Runtime error routing and error boundaries
- Attribute, URL, style and
srcdocsecurity policies - Browser, Node, bundler and package-consumption verification
Version 4.0.1 followed with a focused fix for reactive class and style bindings. Errors thrown by those bindings now carry their owning DOM node, allowing the nearest ErrorBoundary to catch them correctly.
The public authoring model remains familiar: signals, functions and real DOM.
A framework without a mandatory build step
“No build step required” does not mean build tools are forbidden. SibuJS can be used directly in a browser, but it also supports npm, TypeScript, Vite, Webpack and normal production bundling.
The distinction is that the runtime does not depend on a compiler to discover reactivity. A signal read is still reactive at runtime, whether the source passed through a build tool or not.
For advanced applications, SibuJS provides optional subpath modules:
import { query, mutation } from "sibujs/data";
import { createRouter, RouterLink } from "sibujs/plugins";
import { renderToString, hydrate } from "sibujs/ssr";
import { virtualList, toast } from "sibujs/ui";
This keeps those features outside applications that do not import them.
Runtime requirements
SibuJS 4.0.1 targets:
- Chrome and Edge 93 or newer
- Firefox 92 or newer
- Safari 15.4 or newer
- Node.js 22.3 or newer for SSR and tooling
SSR currently uses replacement hydration: the client creates its reactive tree and replaces the inert server-rendered subtree. This avoids partially adopted bindings, but it does not preserve pre-hydration DOM identity, focus or user-entered form state. It is an intentional tradeoff and worth considering when choosing an SSR strategy.
Try it
SibuJS is open source and available under the MIT license.
- Documentation: sibujs.dev
- GitHub: github.com/hexplus/sibujs
- npm: npmjs.com/package/sibujs
- SibuJS UI: github.com/hexplus/sibujs-ui
If you like direct DOM rendering and fine-grained reactivity but want more than a minimal reactive kernel, give SibuJS 4.0.1 a try. Feedback, examples and real-world use cases are especially welcome—the framework becomes better when it is tested outside the scenarios its author expected.
This article was originally published by DEV Community and written by José Ramírez.
Read original article on DEV Community