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:

Option A

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.

terminal
npm install sigwork
export default {
  esbuild: {
    jsxFactory: 'h',
    jsxFragmentFactory: 'null'
  }
}
{
  "compilerOptions": {
    "jsx": "react",
    "jsxFactory": "h",
    "jsxFragmentFactory": "null"
  }
}
main.js — entrypoint
import Sigwork, { h } from 'sigwork'; // h must be in scope for JSX files
import App from './App.jsx';

Sigwork(document.getElementById('app'), App);
Option B

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.

index.html — via esm.sh
<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>
with htm for JSX-like syntax
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)
in JSX
<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>
com Transition
{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 as function / no key
// 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.

array structure
// 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>
enter only (no leave)
// 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 fromtransitionend: remove to + active, add idle

Leave: remove idle, frame 1: add from [1] → frame 2: add active [0] + to [2], remove fromtransitionend: 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.