Sigrea core provides reactive primitives, deep signals, and scope-based molecule lifecycles. It is built on alien-signals.
- Core primitives.
signal,computed,toSignal,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 clean up molecule instances in tests.
Inspired by:
- Vue 3: deep reactivity and scope control
- nanostores: store-centric architecture
- bunshi: molecule concepts and
get()-based parent-child graph design
- Install
- Adapters
- Quick Start
- Plain Helpers
- Molecules
- Testing
- Handling Scope Cleanup Errors
- Development
- License
npm install @sigrea/coreInstall @sigrea/use too if you
copy examples that use helpers from that package, such as createEvents.
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.
import { computed, signal } from "@sigrea/core";
const count = signal(1);
const doubled = computed(() => count.value * 2);
count.value = 3;
console.log(doubled.value); // 6Plain helpers are ordinary functions built from the core primitives.
This package does not include UI bindings.
In UI apps, you usually call these helpers inside a molecule.
Then connect the molecule to the UI layer via an adapter.
watch() and watchEffect() return callable stop handles with pause() and
resume() methods.
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,
};
}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.
Calling the returned factory 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()), - parent-driven inputs via props.
Props passed to setup are stable, shallow readonly, and reactive at the top
level. Official adapters keep them in sync with component props. If you use core
directly, call updateMoleculeProps(instance, nextProps) to replace them.
Read props through props.propName. Destructuring a prop value copies the
current value and loses reactivity. Use toSignal(props, "propName") only when
an internal helper needs a prop-shaped ReadonlySignal; do not return prop
mirrors from a molecule just because a prop is reactive.
The props object must be a plain object. Sigrea syncs enumerable top-level properties and passes nested values as-is.
Molecule setup declares state and registers lifecycle hooks.
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.
This example uses createEvents from
@sigrea/use.
import {
computed,
get,
molecule,
readonly,
signal,
toSignal,
} from "@sigrea/core";
import { createEvents } from "@sigrea/use";
interface DialogProps {
open: boolean;
disabled?: boolean;
}
type DialogEvents = {
"update:open": [next: boolean];
};
const DialogMolecule = molecule((props: DialogProps) => {
const { send, on } = createEvents<DialogEvents>();
const isOpen = toSignal(props, "open");
const isDisabled = computed(() => props.disabled ?? false);
const emitOpenChange = async (next: boolean) => {
if (isDisabled.value || isOpen.value === next) {
return;
}
await send("update:open", next);
};
const open = () => {
return emitOpenChange(true);
};
const close = () => {
return emitOpenChange(false);
};
const toggle = () => {
return emitOpenChange(!isOpen.value);
};
return {
on,
open,
close,
toggle,
};
});
const DialogControllerMolecule = molecule(() => {
const isOpen = signal(false);
const dialog = get(DialogMolecule, () => ({
open: isOpen.value,
}));
dialog.on("update:open", (next) => {
isOpen.value = next;
});
return {
isOpen: readonly(isOpen),
open: dialog.open,
close: dialog.close,
toggle: dialog.toggle,
};
});This pattern keeps the controlled value in a parent or controller molecule. The
child molecule reads props internally and sends update:open when it wants its
controller to replace the value. Framework adapters mount the controller molecule.
Components read the controller-owned signals and computed values it returns; raw
molecule events stay inside the molecule graph.
import { computed, get, molecule } from "@sigrea/core";
interface TabItemProps {
selectedId: string;
id: string;
}
const TabItemMolecule = molecule((props: TabItemProps) => {
const isSelected = computed(() => props.selectedId === props.id);
return {
isSelected,
};
});
interface TabsProps {
selectedId: string;
itemId: string;
}
const TabsMolecule = molecule((props: TabsProps) => {
const item = get(TabItemMolecule, () => ({
id: props.itemId,
selectedId: props.selectedId,
}));
return {
isItemSelected: item.isSelected,
};
});Notes:
- Use
computed()for derived state. UsetoSignal(props, "key")only when an internal helper needs a signal view of a prop, not as a default return shape. - Use
get()to create and own child molecule instances. get()must be called synchronously during molecule setup.get(Child, props)passes a static props snapshot. Useget(Child, () => ({ ... }))to derive child props reactively from parent props. The child instance is not recreated when live props change.- Props sync is top-level only. Nested objects are passed through as values; replace the top-level prop when a nested value must notify dependents.
onUnmount()callbacks andwatch()effects are tied to the mount lifecycle.watch()andwatchEffect()return callable stop handles; calling a handle directly is equivalent to callinghandle.stop(), and each handle also exposeshandle.pause()andhandle.resume().handle.pause()suspends only that watcher. Calling it does not run cleanup callbacks or dispose the current scope; if changes happened while paused,handle.resume()runs once with the latest value when needed.handle.pause()/handle.resume()are separate frompauseTracking()/resumeTracking(), which control dependency collection while code runs.- Child molecules created via
get()are disposed with their parent.
// 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.
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.
This repo targets Node.js 24 or later.
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 -ytrustsmise.toml(first run only).pnpm -s cicheckruns CI-equivalent checks locally.mise run notespreviews release notes (optional).
You can also run pnpm scripts directly:
pnpm installinstalls dependencies.pnpm testruns tests.pnpm typecheckruns TypeScript type checking.pnpm test:coveragecollects coverage.pnpm buildbuilds the package.pnpm -s cicheckruns CI checks locally.
See CONTRIBUTING.md for workflow details.
MIT. See LICENSE.
