Reference Manual
Fine-grained reactivity, no Virtual DOM, JSX compatible. 1.7kb gzipped.
Introduction
Sigwork is a minimalist frontend framework based on signals. Instead of maintaining a Virtual DOM and reconciling it on every state change, it connects signals directly to the DOM nodes and attributes that depend on them — when a signal changes, only that point of the DOM is updated.
Components are ordinary JavaScript functions that execute once. All reactivity is built by passing getter functions as children or props — the renderer detects this and installs an effect automatically.
Memory management is automatic: each component, dynamic zone, and list item has a reactive scope that is destroyed along with its DOM node, preventing leaks.
1.7kb
gzipped
0
dependencies
JSX
compatible
Installation & Setup
There are two ways to use Sigwork. Choose the one that best fits your project:
With a build step (npm + Vite / esbuild / tsc)
Install the package and configure the JSX pragma for your bundler. Sigwork uses h as the JSX factory.
npm install sigwork
export default { esbuild: { jsxFactory: 'h', jsxFragmentFactory: 'null' } }
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "null"
}
}
import Sigwork, { h } from 'sigwork'; // h must be in scope for JSX files import App from './App.jsx'; Sigwork(document.getElementById('app'), App);
No build — CDN / Native ESM
For prototypes or simple pages, import Sigwork directly from a CDN as an ES module. In this mode there is no JSX — use the h() function directly to compose the UI, or pair it with htm for a JSX-like template literal syntax.
<script type="module"> import Sigwork, { signal, h } from 'https://esm.sh/sigwork'; const App = () => { const count = signal(0); return h('div', null, h('p', null, () => String(count.value)), h('button', { onClick: () => count.value++ }, '+') ); }; Sigwork(document.getElementById('app'), App); </script>
import Sigwork, { h, signal } from 'https://esm.sh/sigwork'; import htm from 'https://esm.sh/htm'; const html = htm.bind(h); const App = () => { const count = signal(0); return html` <div> <p>Count: ${() => count.value}</p> <button onClick=${() => count.value++}>+</button> </div> `; }; Sigwork(document.getElementById('app'), App);
signal()
signal<T>(initialValue: T) → { value: T }
The basic unit of reactive state. Returns an object with a .value property. Any effect or dynamic zone that reads .value during its execution automatically becomes a subscriber.
import { signal } from 'sigwork'; const count = signal(0); count.value; // read: 0 count.value = 5; // write: notifies all subscribers count.value++; // shorthand, works fine
Inside JSX, wrap the read in an arrow function to create a live binding to the DOM node. Without the function, the value is read once at render time and never updates.
const name = signal('world'); const App = () => ( <div> {/* ✗ — read once, never updates */} <p>Hello, {name.value}</p> {/* ✓ — live reactive binding */} <p>Hello, {() => name.value}</p> </div> );
Equality check: if the new value is strictly equal (===) to the current one, the signal does not notify any subscriber. For objects and arrays, this means direct mutation won’t trigger updates — use reactive() for those cases.
reactive()
reactive<T extends object>(obj: T) → T
Makes a plain object or array fully reactive via Proxy. Unlike signal, reactivity is per-property: changing state.name only notifies effects that read state.name.
import { reactive } from 'sigwork'; const state = reactive({ user: { name: 'Alice', age: 30 }, items: ['a', 'b', 'c'] }); state.user.name = 'Bob'; // ✓ reactive, deep nesting works state.items.push('d'); // ✓ arrays work
In JSX, use it the same way as a signal: wrap the read in a function.
const Profile = () => (
<div>
<p>{() => state.user.name}</p>
<p>{() => state.user.age}</p>
</div>
);
Stable identity: nested objects are cached in a WeakMap, so state.user === state.user is always true. Sub-properties are only made reactive on first access (lazy).
computed()
computed<T>(fn: () → T) → { readonly value: T }
Derives a value from other signals or reactives. It recalculates automatically when its dependencies change, and only recalculates when needed — dependents are notified only if the computed value actually changed.
const price = signal(100); const qty = signal(3); const total = computed(() => price.value * qty.value); total.value; // 300 total.value = 999; // ✗ TypeError — computed is read-only price.value = 200; total.value; // 600 (recalculated)
<p>Total: {() => total.value.toFixed(2)}</p>
effect()
effect(fn: () → void | StopFn) → stop: () → void
Runs fn immediately and tracks all signal reads as dependencies. Whenever a dependency changes the effect re-runs — but before re-running it cleans up: dependencies are removed and the previous cleanup function (if any) is called.
const query = signal(''); const stop = effect(() => { const controller = new AbortController(); fetch(`/search?q=${query.value}`, { signal: controller.signal }) .then(r => r.json()) .then(data => console.log(data)); // cleanup: cancels the previous request before re-running return () => controller.abort(); }); stop(); // stop the effect manually
Dynamic dependencies: dependencies are re-collected on every run. An effect reading a.value || b.value — if a is truthy, b is never read and is not registered as a dependency for that run.
watch()
watch(source: Signal | (() → T), cb: (newValue: T, oldValue: T) → void) → stop: () → void
Watches a reactive source and calls cb with the new and previous value on every change. Unlike effect, it does not run on initialization — only on subsequent changes.
const theme = signal('light'); // source as signal watch(theme, (next, prev) => { document.body.classList.remove(prev); document.body.classList.add(next); }); // source as getter (allows derived values) watch(() => state.user.role, (role) => { console.log('Role changed:', role); });
Return value: like effect, returns a stop() function to cancel the watcher manually.
untrack()
untrack<T>(fn: () → T) → T
Runs fn without registering any signal reads as dependencies of the active effect. Useful for reading the current value of a signal without making the effect depend on it.
const trigger = signal(0); const log = signal([]); effect(() => { trigger.value; // dependency: re-runs when trigger changes // reads log.value without creating a dependency — the effect // does not re-run when log changes const current = untrack(() => log.value); console.log('trigger fired, log has:', current.length); });
scope()
scope(fn: () → void | (() → void)) → stop: () → void
Creates a reactive scope that collects all effects created inside it. Calling stop() stops every effect in the scope at once — including those in nested child scopes. Sigwork uses scope internally for the automatic cleanup of components and list items.
import { scope, effect, signal } from 'sigwork'; const a = signal(1); const b = signal(2); const stop = scope(() => { effect(() => console.log('a:', a.value)); effect(() => console.log('b:', b.value)); // optional return — treated as the scope's cleanup callback return () => console.log('scope destroyed'); }); stop(); // both effects stop, cleanup runs
Nested scopes: if scope() is called inside another scope, the child is registered in the parent. Destroying the parent automatically destroys all children in cascade.
h() & JSX
h(tag: string | null | Component, props: object | null, ...children) → Node | Function
Sigwork’s render function — exposed globally as window.h and used by the JSX compiler as the factory. You rarely call it directly.
When tag is a string, it creates a real DOM element. When it’s a function, it executes as a component and returns the result. When it’s null, it returns a fragment (the children array as a function).
// JSX — preferred const el = <div class="card"><p>Hello</p></div>; // equivalent without JSX const el = h('div', { class: "card" }, h('p', null, 'Hello')); // fragment — groups nodes without a wrapper const frag = <><p>One</p><p>Two</p></>; // must be used as a child, not as root
Props & Events
Sigwork distinguishes three prop types automatically:
1. Events — onXxx
Any prop starting with on is registered as an event listener. The name is lowercased automatically.
<button onClick={() => alert('clicked')}>Click</button>
<input onInput={(e) => name.value = e.target.value} />
2. Reactive props — value as function
If the prop value is a function, it is wrapped in an effect. The prop updates the DOM whenever the signals it reads change.
const disabled = signal(false); const label = signal('Save'); <button disabled={() => disabled.value} class={() => disabled.value ? 'opacity-50' : ''} > {() => label.value} </button>
3. Static props
Primitive values are applied once. If the key exists as a DOM property (e.g. className, value), it is assigned directly; otherwise setAttribute is used.
<input type="text" placeholder="Nome..." maxLength={50} />
Dynamic Children
When a JSX child is a function, Sigwork creates an anchor in the DOM and an effect that re-evaluates the function and reconciles the resulting nodes on every change. This is the mechanism behind If(), For(), and any conditional expression.
const show = signal(true); const items = signal(['a', 'b', 'c']); const App = () => ( <div> {/* reactive text */} <p>{() => show.value ? 'visible' : 'hidden'}</p> {/* conditional node */} {() => show.value && <span>Appears!</span>} {/* dynamic list of nodes */} {() => items.value.map(i => <li>{i}</li>)} </div> );
Automatic diff: on re-run, the engine compares previous nodes with new ones. Nodes that remain in the list are reused (not recreated). Insertions, removals, and moves are detected with a two-pointer algorithm optimised for the common cases.
ref
To get a direct reference to a DOM node, pass a signal (or any object with .value) as the ref prop. Sigwork assigns the element to .value during rendering.
import { signal, onMount } from 'sigwork'; const Input = () => { const el = signal(null); onMount(() => el.value.focus()); return <input ref={el} type="text" />; };
Creating Components
A component is a function that receives props and a second argument { emit, children }, and returns a DOM node or a function (for components without a single root node).
The function runs exactly once. All reactivity must be expressed through signals, computed values, or functions passed to JSX — never re-read props inside effects expecting them to change, as props are also static by default.
import { signal, computed } from 'sigwork'; const Counter = (props) => { const count = signal(props.start ?? 0); const label = computed(() => count.value === 0 ? 'zero' : String(count.value) ); return ( <div> <p>{() => label.value}</p> <button onClick={() => count.value++}>+</button> <button onClick={() => count.value--}>−</button> </div> ); }; // usage <Counter start={10} />
emit
The second argument of a component contains the emit function, which lets you dispatch typed events to the parent component. Convention: emit('change', value) in the child calls the parent’s onChange prop.
// Child component const Toggle = (props, { emit }) => { const on = signal(false); const toggle = () => { on.value = !on.value; emit('change', on.value); // fires onChange on parent }; return <button onClick={toggle}>{() => on.value ? 'ON' : 'OFF'}</button>; }; // Parent component <Toggle onChange={(val) => console.log('state:', val)} />
children
JSX nodes passed between a component’s opening and closing tags arrive as the children array in the second argument. You can render them directly or forward them to other elements.
const Card = (props, { children }) => ( <div class="card"> {props.title && <h2>{props.title}</h2>} <div class="card-body"> {...children} </div> </div> ); // usage <Card title="My Card"> <p>Content here.</p> <button>Action</button> </Card>
onMount / onUnmount
Lifecycle hooks called during component setup. onMount fires after the component’s root node is inserted into the DOM. onUnmount fires before the node is removed — whether by a conditional (If()), list removal, or app teardown.
import { onMount, onUnmount, signal } from 'sigwork'; const Clock = () => { const time = signal(new Date().toLocaleTimeString()); onMount(() => { const id = setInterval( () => time.value = new Date().toLocaleTimeString(), 1000 ); onUnmount(() => clearInterval(id)); }); return <p>{() => time.value}</p>; };
Recommended pattern: registering onUnmount inside onMount ensures cleanup only occurs if the mount actually happened — useful for external resources that depend on IDs or references obtained at mount time.
provide / inject
Allows a parent component to make values available to all descendants without passing props manually. provide(key, value) registers the value in the current component’s context. inject(key, default) looks up the nearest value in the hierarchy, or returns default if not found.
import { provide, inject, signal } from 'sigwork'; // Root component — provides the theme const App = () => { const theme = signal('dark'); provide('theme', theme); return <Layout />; }; // Any descendant — consumes the theme const Button = () => { const theme = inject('theme'); return ( <button class={() => theme.value === 'dark' ? 'btn-dark' : 'btn-light'}> Click </button> ); };
If()
If(cond: () → boolean, renderFn: () → Node, fallback?: Node | (() → Node)) → () → Node
Renders the result of renderFn() when cond() is truthy, and fallback (optional) when falsy. Returns a getter function that must be used as a dynamic child in JSX. The reactive scope of the child is destroyed when it leaves the DOM.
import { If, signal } from 'sigwork'; const isLoggedIn = signal(false); // embed the return value directly as a JSX child <div> {If( () => isLoggedIn.value, () => <Dashboard />, () => <Login /> // optional fallback — can also be a static Node )} </div>
{If(
() => isOpen.value,
() =>
<Transition
enter={['transition-all duration-300 ease-out', 'opacity-0 translate-y-2', 'opacity-100 translate-y-0']}
leave={['transition-all duration-200 ease-in', 'opacity-100 translate-y-0', 'opacity-0 translate-y-2']}
>
<div class="modal">Content</div>
</Transition>
)}
Note: when the child is wrapped in a <Transition>, the element waits for the leave animation to finish before being removed from the DOM. See <Transition>.
For()
For(list: signal | (() → T[]), key: string | ((item, index) → any) | null, renderFn: (item: T) → Node) → () → Node[]
Renders a reactive list with key-based caching. Returns a getter function that must be used as a dynamic child in JSX. When the list changes, existing nodes are reused — only inserting, removing, or moving what is necessary.
import { For, signal } from 'sigwork'; const todos = signal([ { id: 1, text: 'Buy bread' }, { id: 2, text: 'Learn Sigwork' }, ]); // list key render per item <ul> {For(todos, 'id', item => <li>{item.text}</li>)} </ul>
// key derived by function {For(todos, (item) => item.id, item => <li>{item.text}</li>)} // no key — uses object reference (fine for unique objects) {For(todos, null, item => <li>{item.text}</li>)} // list as getter (allows deriving/filtering before rendering) {For(() => todos.value.filter(t => !t.done), 'id', item => <li>{item.text}</li>)}
The key argument
Defines how to identify each item in the cache. Can be a property name ("id"), a function (item, index) => key, or null — without a key, the object reference is used (works for objects, not for duplicate primitives).
Per-item cleanup
Each item has its own reactive scope. Effects and watchers created inside renderFn are destroyed automatically when the item is removed from the list.
<Transition>
{ enter?: [active, from, to], idle?: string, leave?: [active, from, to] }
Wraps a single child element and manages CSS enter/leave classes via three-string arrays. On leave, the node stays in the DOM until the transitionend event fires, preventing abrupt removal. Compatible with Tailwind and any CSS framework.
// enter / leave = [transitionClasses, initialState, finalState] <Transition enter={[ 'transition-all duration-300 ease-out', // animation classes [0] 'opacity-0 -translate-y-2', // initial state "from" [1] 'opacity-100 translate-y-0' // final state "to" [2] ]} idle="opacity-100" // class when stable (optional) leave={[ 'transition-all duration-200 ease-in', 'opacity-100 translate-y-0', 'opacity-0 -translate-y-2' ]} > {If(() => show.value, () => <div class="modal">Content</div>)} </Transition>
// leave can be omitted for enter-only transitions <Transition enter={['transition-opacity duration-500', 'opacity-0', 'opacity-100']} > <p>Fades in smoothly</p> </Transition>
Class sequence
Enter: frame 1: add from [1] → frame 2: add active [0] + to [2], remove from → transitionend: remove to + active, add idle
Leave: remove idle, frame 1: add from [1] → frame 2: add active [0] + to [2], remove from → transitionend: node removed from DOM
Nodes in a leave transition never interfere with list or dynamic zone diffs — the algorithm ignores them until they are silently removed at the end of the animation.
<Component>
{ is: Component | () → Component, ...props }
Allows rendering a component dynamically, determining which one to use at runtime. The is prop can be a static component or a reactive getter function — when reactive, swapping the component remounts the tree.
const views = { home: Home, about: About, contact: Contact }; const route = signal('home'); <Component is={() => views[route.value]} /> // Switching routes remounts the entire component <button onClick={() => route.value = 'about'}>About</button>
App Mounting
The default export of Sigwork is the mount function. It takes a target DOM element, the root component, and optional props. It returns an unmount function that cleans up all effects and removes the element.
import Sigwork from 'sigwork'; import App from './App.jsx'; const unmount = Sigwork( document.getElementById('app'), App, { initialTheme: 'dark' } // props for the root component (optional) ); // completely unmounts the app with no leaks unmount();
Unmounting is complete: it destroys the root reactive scope (stopping all effects across every nesting level), calls all onUnmount callbacks, and removes the element from the DOM.
Known Limitations
Fragments cannot be used as the root component
A fragment (<>...</>) works as a child inside any element, but cannot be passed as the root to Sigwork(target, root). The mount function expects a single DOM node. Use a wrapper element instead.
// ❌ won’t work Sigwork(document.getElementById('app'), () => <><Header /><Main /></>); // ✅ wrap in a container Sigwork(document.getElementById('app'), () => <div><Header /><Main /></div>);
<Transition> leave animation is not reversible mid-exit
Once a leave animation starts, it runs to completion before the node is removed. If the condition becomes truthy again while the node is still leaving, the transition does not interrupt — the enter animation will start after the leave finishes.
Shared HTML/SVG tag names inside SVG trees
Tags that exist in both the HTML and SVG namespaces — <a>, <style>, <script>, <title> — are always created in the HTML namespace. Avoid using them directly inside SVG element trees.