@sigrea/core
v0.5.0
Published
The signal base reactive programming library.
Maintainers
Readme
@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, andwatchEffect. - Lifecycles.
Scope,onMount, andonUnmountfor cleanup boundaries. - Molecules.
molecule()is a lifecycle container that doesn't render UI. - Composition. Build molecule trees via
get(). - Testing.
trackMolecule+disposeTrackedMoleculeshelps reproduce lifecycles in tests.
Inspired by:
- Vue 3 — deep reactivity and scope control
- nanostores — store-centric architecture
- bunshi — molecule and composition API design
Table of Contents
Install
npm install @sigrea/coreAdapters
Official adapters connect Sigrea molecules and signals to UI frameworks:
- @sigrea/vue — Vue 3.4+ composables (
useMolecule,useSignal,useMutableSignal,useDeepSignal) - @sigrea/react — React 18+ hooks (
useMolecule,useSignal,useComputed,useDeepSignal)
Each adapter binds molecule lifecycles to component lifecycles and synchronizes signal subscriptions with the framework's reactivity system.
Quick Start
Signals and Computed
import { computed, signal } from "@sigrea/core";
const count = signal(1);
const doubled = computed(() => count.value * 2);
count.value = 3;
console.log(doubled.value); // 6Hooks
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.
Example: state + actions
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,
};
}Example: deepSignal for nested state
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,
};
}Molecules
molecule(setup) creates a function.
Calling it creates a new instance with its own root Scope.
It does not render anything.
Use molecules when you need:
- a clear ownership + cleanup boundary (
Scope,onUnmount), - parent-child relationships between lifecycled units (
get()), - 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,
their work is deferred until the molecule is mounted.
Official adapters mount and unmount molecules automatically.
If you use the core package directly, call mountMolecule() 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.
Creating a molecule
import { molecule, onMount, onUnmount, readonly, signal } from "@sigrea/core";
interface IntervalMoleculeProps {
intervalMs: number;
}
const IntervalMolecule = molecule<IntervalMoleculeProps>((props) => {
const tick = signal(0);
let id: ReturnType<typeof setInterval> | undefined;
onMount(() => {
id = setInterval(() => {
tick.value += 1;
}, props.intervalMs);
});
onUnmount(() => {
if (id === undefined) {
return;
}
clearInterval(id);
});
return {
tick: readonly(tick),
};
});Composing molecules with get()
import { get, molecule, readonly, signal, watch } from "@sigrea/core";
interface DraftSessionMoleculeProps {
intervalMs: number;
initialText: string;
save: (text: string) => void;
}
export const DraftSessionMolecule = molecule<DraftSessionMoleculeProps>(
(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 andwatch()effects are tied to the mount lifecycle.- Child molecules created via
get()are disposed with their parent.
Testing
// 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);
});Handling Scope Cleanup Errors
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.
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.
Return ScopeCleanupErrorResponse.Propagate to rethrow immediately for synchronous errors.
Development
This repo targets Node.js 20 or later.
Browser dev flag
Some dev-only diagnostics are guarded by __DEV__.
In Node.js, Sigrea uses process.env.NODE_ENV !== "production".
In browsers, you can override this at build time by defining a global constant
__SIGREA_DEV__ with your bundler.
If you don't define it, __DEV__ defaults to false in browsers.
Vite example:
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig(({ command }) => ({
define: {
__SIGREA_DEV__: command === "serve",
},
}));If you use mise:
mise trust -y— trustmise.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.
License
MIT — see LICENSE.
