The signal base reactive programming library.
npm install @sigrea/core

Sigrea is a small reactive core built on alien-signals.
It adds deep reactivity and scope-based lifecycles.
It provides core primitives to build hooks, plus optional lifecycles for ownership and cleanup.
- Core primitives. signal, computed, deepSignal, watch, and watchEffect.
- Lifecycles. Scope, onMount, and onUnmount for cleanup boundaries.
- Molecules. molecule() is a lifecycle container that doesn't render UI.
- Composition. Build molecule trees via get().
- Testing. trackMolecule + disposeTrackedMolecules helps reproduce lifecycles in tests.
Inspired by:
- Vue 3 — deep reactivity and scope control
- nanostores — store-centric architecture
- bunshi — molecule and composition API design
- Install
- Adapters
- Quick Start
- Hooks
- Molecules
- Testing
- Handling Scope Cleanup Errors
- Development
- License
``bash`
npm install @sigrea/core
Official adapters connect Sigrea molecules and signals to UI frameworks:
- @sigrea/vue — Vue 3.4+ composables (useMolecule, useSignal, useMutableSignal, useDeepSignal)useMolecule
- @sigrea/react — React 18+ hooks (, useSignal, useComputed, useDeepSignal)
Each adapter binds molecule lifecycles to component lifecycles and synchronizes signal subscriptions with the framework's reactivity system.
`ts
import { computed, signal } from "@sigrea/core";
const count = signal(1);
const doubled = computed(() => count.value * 2);
count.value = 3;
console.log(doubled.value); // 6
`
Hooks are plain functions built from the core primitives.
This package does not include UI bindings.
In UI apps, you usually call hooks inside a molecule.
Then connect the molecule to the UI layer via an adapter.
`ts
import { computed, readonly, signal } from "@sigrea/core";
export function useCounter(initial = 0) {
const count = signal(initial);
const doubled = computed(() => count.value * 2);
const increment = () => {
count.value++;
};
const decrement = () => {
count.value--;
};
return {
count: readonly(count),
doubled,
increment,
decrement,
};
}
`
`ts
import { computed, deepSignal } from "@sigrea/core";
export function useUserProfile() {
const profile = deepSignal({
name: "Mendako",
address: { city: "Tokyo" },
});
const label = computed(() => {
return ${profile.name} @ ${profile.address.city};
});
const setCity = (city: string) => {
profile.address.city = city;
};
return {
profile,
label,
setCity,
};
}
`
molecule(setup) creates a function.Scope
Calling it creates a new instance with its own root .
It does not render anything.
Use molecules when you need:
- a clear ownership + cleanup boundary (Scope, onUnmount),get()
- parent-child relationships between lifecycled units (),
- per-instance initial configuration via props.
Props are meant to be immutable configuration. Sigrea does not track prop changes.
If you need dynamic inputs, model them via signals or explicit molecule methods.
Molecule setup only constructs state.
When onMount, onUnmount, watch, or watchEffect are called during setup,mountMolecule()
their work is deferred until the molecule is mounted.
Official adapters mount and unmount molecules automatically.
If you use the core package directly, call and unmountMolecule().
Inside setup, you can call hooks or use the core primitives directly.
Child molecules are internal dependencies—prefer returning only the outputs
(signals, computed values, actions) that consumers need.
`ts
import { molecule, onMount, onUnmount, readonly, signal } from "@sigrea/core";
interface IntervalMoleculeProps {
intervalMs: number;
}
const IntervalMolecule = molecule
const tick = signal(0);
let id: ReturnType
onMount(() => {
id = setInterval(() => {
tick.value += 1;
}, props.intervalMs);
});
onUnmount(() => {
if (id === undefined) {
return;
}
clearInterval(id);
});
return {
tick: readonly(tick),
};
});
`
`ts
import { get, molecule, readonly, signal, watch } from "@sigrea/core";
interface DraftSessionMoleculeProps {
intervalMs: number;
initialText: string;
save: (text: string) => void;
}
export const DraftSessionMolecule = molecule
(props) => {
const text = signal(props.initialText);
const isDirty = signal(false);
const setText = (next: string) => {
text.value = next;
isDirty.value = true;
};
const save = () => {
props.save(text.value);
isDirty.value = false;
};
const interval = get(IntervalMolecule, {
intervalMs: props.intervalMs,
});
watch(interval.tick, () => {
if (!isDirty.value) {
return;
}
save();
});
return {
isDirty: readonly(isDirty),
setText,
save,
text: readonly(text),
};
},
);
`
Notes:
- get() must be called synchronously during molecule setup.onUnmount()
- callbacks and watch() effects are tied to the mount lifecycle.get()
- Child molecules created via are disposed with their parent.
`ts
// tests/CounterMolecule.test.ts
import { afterEach, expect, it } from "vitest";
import {
disposeTrackedMolecules,
molecule,
readonly,
signal,
trackMolecule,
} from "@sigrea/core";
afterEach(() => disposeTrackedMolecules());
it("increments and exposes derived state", () => {
const CounterMolecule = molecule(() => {
const count = signal(10);
const increment = () => {
count.value++;
};
return {
count: readonly(count),
increment,
};
});
const counter = CounterMolecule();
trackMolecule(counter);
counter.increment();
expect(counter.count.value).toBe(11);
});
`
Cleanup callbacks run when a scope is disposed.
If a cleanup throws, Sigrea collects errors into an AggregateError.
Async cleanups are not awaited.
If an async cleanup rejects, Sigrea forwards the error to the handler (if any).
In dev, Sigrea also logs the rejection.
Use setScopeCleanupErrorHandler to customize error handling.
This is useful for logging or reporting to monitoring services.
`ts
import { setScopeCleanupErrorHandler } from "@sigrea/core";
setScopeCleanupErrorHandler((error, context) => {
console.error(Cleanup failed:, error);
// Forward to monitoring service
if (typeof Sentry !== "undefined") {
Sentry.captureException(error, {
tags: { scopeId: context.scopeId, phase: context.phase },
});
}
});
`
The handler receives error and context.context includes scopeId, phase, index, and total.
Return ScopeCleanupErrorResponse.Suppress to prevent the error from being thrown.ScopeCleanupErrorResponse.Propagate
Return to rethrow immediately for synchronous errors.
This repo targets Node.js 20 or later.
Some dev-only diagnostics are guarded by __DEV__.process.env.NODE_ENV !== "production"
In Node.js, Sigrea uses .
In browsers, you can override this at build time by defining a global constant
__SIGREA_DEV__ with your bundler.__DEV__
If you don't define it, defaults to false in browsers.
Vite example:
`ts
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig(({ command }) => ({
define: {
__SIGREA_DEV__: command === "serve",
},
}));
`
If you use mise:
- mise trust -y — trust mise.toml (first run only).mise run ci
- — run CI-equivalent checks locally.mise run notes
- — preview release notes (optional).
You can also run pnpm scripts directly:
- pnpm install — install dependencies.pnpm test
- — run tests.pnpm typecheck
- — run TypeScript type checking.pnpm test:coverage
- — collect coverage.pnpm build
- — build the package.pnpm cicheck` — run CI checks locally.
-
See CONTRIBUTING.md for workflow details.
MIT — see LICENSE.