equiljs
v0.1.0
Published
EquilJS — Deterministic, signal-based UI runtime. No VDOM, no hidden re-renders, no lifecycle surprises.
Downloads
25
Maintainers
Readme
What is EquilJS?
A zero-dependency UI framework built on fine-grained signals, transactional commits, and explicit scopes. No virtual DOM, no hidden re-renders, no lifecycle surprises.
import { signal, derived, createScope, createEffect } from "equiljs";
const count = signal(0);
const double = derived(() => count.get() * 2);
const scope = createScope();
createEffect(scope, () => {
console.log(`${count.get()} × 2 = ${double.get()}`);
});
count.set(5); // logs: 5 × 2 = 10Install
npm install equiljsImport paths
// Everything
import { signal, derived, h, render, Router } from "equiljs";
// Only what you need
import { signal, derived, transaction } from "equiljs/core";
import { h, Fragment, render } from "equiljs/dom";
import { Router, Route, Link, navigate } from "equiljs/router";TypeScript + JSX setup
Add these compiler options to your tsconfig.json:
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"moduleResolution": "node"
}
}Then import h and Fragment in any .tsx file:
import { h, Fragment } from "equiljs/dom";60-Second Tutorial
1. Create a signal
import { signal } from "equiljs/core";
const name = signal("Alice");
name.get(); // "Alice"
name.set("Bob"); // updates all subscribers2. Derive computed values
import { signal, derived } from "equiljs/core";
const count = signal(3);
const double = derived(() => count.get() * 2);
double.get(); // 6 — recomputed lazily when count changes3. React to changes with effects
import { signal, createScope, createEffect } from "equiljs/core";
const scope = createScope();
const temp = signal(72);
createEffect(scope, () => {
console.log(`Temperature: ${temp.get()}°F`);
});
// logs immediately: Temperature: 72°F
temp.set(85);
// logs: Temperature: 85°F4. Batch updates in a transaction
import { signal, transaction } from "equiljs/core";
const x = signal(0);
const y = signal(0);
transaction(() => {
x.set(10);
y.set(20);
});
// effects run ONCE after both values are committed5. Render to the DOM
import { h, render } from "equiljs/dom";
import { signal } from "equiljs/core";
function Counter() {
const count = signal(0);
return (
<div>
<p>Count: {() => count.get()}</p>
<button onClick={() => count.set(count.get() + 1)}>+1</button>
</div>
);
}
render(() => <Counter />, document.getElementById("root")!);API Reference
Core (equiljs/core)
| Export | Description |
|---|---|
| signal(init) | Create a reactive state holder. .get() reads, .set(v) writes. Same-value sets are no-ops. |
| derived(fn) | Create a lazily-evaluated computed value. Dependencies tracked automatically. |
| transaction(fn) | Batch multiple .set() calls — effects run once after commit. |
| createScope() | Create a lifecycle scope that owns effects and cleanup callbacks. |
| createEffect(scope, fn) | Register a reactive side-effect. Re-runs when dependencies change. Return a function for cleanup. |
| onMount(scope, fn) | Run fn once after scope creation (async, via microtask). |
| onCleanup(scope, fn) | Register a teardown callback. Runs in LIFO order on disposeScope(). |
| disposeScope(scope) | Dispose the scope: stop all effects, run cleanups in LIFO order. |
DOM (equiljs/dom)
| Export | Description |
|---|---|
| h(tag, props, ...children) | JSX factory. Creates DOM elements with reactive props and children. |
| Fragment | JSX fragment — groups children without a wrapper element. |
| render(factory, container) | Mount a component tree into a DOM container. |
Router (equiljs/router)
| Export | Description |
|---|---|
| Router | Root component that provides routing context. |
| Route | Renders its component when the path matches. |
| Switch | Renders the first matching route from an array. |
| Link | Anchor that navigates without full page reload. |
| navigate(path) | Programmatic navigation. |
| useRouter() | Access the current path signal and params. |
JSX & DOM
Components
Any function that accepts props and returns a Node:
function Badge({ label, color }: { label: string; color: string }) {
return <span style={{ backgroundColor: color }}>{label}</span>;
}Reactive children
Wrap signal reads in a function to make them reactive:
<p>{() => count.get()}</p> // updates when count changes
<p>{"static text"}</p> // never updatesConditional rendering
<div>{() => isLoggedIn.get() ? <Dashboard /> : <Login />}</div>Lists
<ul>
{() => items.get().map(item => <li>{item.name}</li>)}
</ul>Event handlers & refs
<input
onInput={(e) => query.set(e.target.value)}
ref={(el) => el.focus()}
/>Router
import { h, render } from "equiljs/dom";
import { Router, Switch, Link, navigate } from "equiljs/router";
function App() {
return (
<Router>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
<Switch routes={[
{ path: "/", component: Home },
{ path: "/user/:id", component: UserProfile },
{ path: "*", component: NotFound },
]} />
</Router>
);
}
render(() => <App />, document.getElementById("root")!);Patterns: /user/:id (named param), /docs/* (wildcard), * (catch-all)
Programmatic navigation:
navigate("/user/42");Guides
Reactivity model
Signals are the single source of truth. derived values recompute lazily on read. Effects re-run synchronously when their dependencies change (or after a transaction commits).
signal.set() → mark derived dirty → schedule effects → run effects (registration order)Transaction model
Inside transaction(), signal writes are deferred. After the function returns:
- Apply all dirty signal values
- Mark dependent deriveds dirty
- Run effects in registration order — each effect sees the fully committed state
Nested transactions delegate to the outermost one.
Scope lifecycle
const scope = createScope();
createEffect(scope, () => { /* tracked */ });
onMount(scope, () => { /* runs once, async */ });
onCleanup(scope, () => { /* runs on dispose, LIFO */ });
disposeScope(scope); // stops effects, runs cleanupsError safety
| Error | When |
|---|---|
| PureRenderViolationError | signal.set() called inside a derived computation |
| CircularDependencyError | A derived value depends on itself (directly or indirectly) |
Both are thrown synchronously and immediately — no silent failures.
Why EquilJS?
| | EquilJS | React | Solid | Svelte |
|---|---|---|---|---|
| Reactivity | Fine-grained signals | VDOM diffing | Fine-grained signals | Compiler-based |
| Update granularity | Exact DOM node | Component tree | Exact DOM node | Component tree |
| State mutations | Explicit .set() | setState / reducer | Explicit .set() | Assignment (=) |
| Render purity | Runtime-enforced | Convention only | Convention only | N/A |
| Bundle size | ~3 KB | ~40 KB | ~7 KB | ~2 KB (compiled) |
| Dependencies | 0 | 0 | 0 | 0 (runtime) |
Project structure
equiljs/
├── src/
│ ├── core/ # Reactive engine (platform-agnostic)
│ ├── dom/ # DOM renderer + JSX factory
│ ├── router/ # Client-side router
│ ├── types/ # JSX type declarations
│ └── index.ts # Main entry — re-exports everything
├── tests/ # 15 tests across 7 files
├── examples/ # Runnable demos
└── tools/ # Dev server (esbuild)Contributing
git clone https://github.com/ash/equiljs.git
cd equiljs
npm install
npm test # 15 tests, zero dependencies
npm run demo # counter + clock demo
npm run serve # dev server at localhost:3000License
MIT
