Using Runtime Core
Renderer-agnostic component model — the essentials.
@sigx/runtime-core is the platform-agnostic heart of the framework: the component system, the JSX runtime, lifecycle hooks, dependency injection, control-flow helpers, and the reconciler base. It has no DOM dependency of its own — a render target (such as the DOM renderer) plugs in via the mount function.
Most applications should install the sigx meta-package instead, which bundles this core with the DOM renderer and @sigx/reactivity so everything is wired up for you. Install @sigx/runtime-core directly when you are building a custom renderer or need the component primitives in isolation.
pnpm add @sigx/runtime-coreTo make TSX compile against this runtime, point your tsconfig.json at the package as the JSX import source:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@sigx/runtime-core"
}
}
Defining a component
A component is a setup function that runs once and returns a render (view) function. Put reactive reads inside the returned function so re-renders track them. The setup receives a context object with props, slots, emit, signal, el, and the lifecycle registrars.
import { component, signal, onMounted, onUnmounted } from '@sigx/runtime-core';
const Timer = component(() => {
const elapsed = signal(0);
let interval: ReturnType<typeof setInterval>;
onMounted(() => {
interval = setInterval(() => elapsed.value++, 1000);
});
onUnmounted(() => clearInterval(interval));
return () => <span>Elapsed: {elapsed.value}s</span>;
});
onMounted, onUnmounted, onCreated, and onUpdated must be called synchronously during setup. Called outside a component they simply warn and no-op. onUnmounted is your cleanup hook — clear timers and unsubscribe there.
Typing props, events, and slots
Type a component through the Define namespace and combine members with the & intersection. The render context destructures props (a reactive accessor), slots (always carries .default()), and emit.
import { component, type Define } from '@sigx/runtime-core';
type ButtonProps =
& Define.Prop<'variant', 'primary' | 'secondary'>
& Define.Prop<'disabled', boolean>
& Define.Event<'click', MouseEvent>
& Define.Slot<'default'>;
const Button = component<ButtonProps>(({ props, slots, emit }) => {
return () => (
<button
class={props.variant}
disabled={props.disabled}
onClick={(e: MouseEvent) => emit('click', e)}
>
{slots.default?.()}
</button>
);
});
Define.Prop, Define.Event, Define.Model, Define.Slot, and Define.Expose are the discoverable way to describe a component's surface.
Creating and mounting an app
defineApp builds a renderer-agnostic application instance with a chainable API. Install plugins with use, register directives with directive, and provide app-wide services with defineProvide, then call mount with a render target.
import { defineApp, defineInjectable } from '@sigx/runtime-core';
const useApiConfig = defineInjectable(() => ({ baseUrl: '' }));
const app = defineApp(<App />);
const config = app.defineProvide(useApiConfig);
config.baseUrl = 'https://api.example.com';
app
.use(myPlugin)
.mount(document.getElementById('app'));
When you use the sigx meta-package, mount already has the DOM renderer wired in. With @sigx/runtime-core alone, pass a MountFn as the second argument to mount so the core knows how to render into your target.
Dependency injection
defineInjectable returns a callable token. Calling it resolves the nearest instance provided up the component tree, or lazily creates a global singleton if none was provided. Provide a fresh instance for a subtree with defineProvide during setup.
import { component, defineInjectable, defineProvide } from '@sigx/runtime-core';
const useStore = defineInjectable(() => ({ count: signal(0) }));
const Provider = component(({ slots }) => {
defineProvide(useStore); // new instance for descendants
return () => <>{slots.default?.()}</>;
});
const Consumer = component(() => {
const store = useStore(); // nearest provided instance
return () => <span>{store.count.value}</span>;
});
defineProvide must run during setup — calling it elsewhere throws SIGX200. Because an unprovided injectable falls back to a process-wide global singleton, prefer explicit defineProvide in SSR so state does not leak across requests.
For services that need managed disposal and a real lifetime, use defineFactory(setup, lifetime) with a Lifetime string literal: 'singleton' (one instance per app, disposed on app.unmount()), 'scoped' (nearest defineProvide in the tree, falling back to the app instance), or 'transient' (a new instance per call, disposed with the calling component). This is the machinery @sigx/store's defineStore lifetimes are built on.
Messaging with topics
createTopic<T>() gives you a small in-memory pub/sub primitive. Publishing is error-isolated (a throwing subscriber is logged and never breaks the publisher or its siblings), subscribing inside a component auto-unsubscribes on unmount, and subscribing to a destroyed topic throws.
import { createTopic } from '@sigx/runtime-core';
const userLoggedIn = createTopic<{ id: string }>({
namespace: 'auth', name: 'loggedIn', // tooling metadata (devtools discovery)
onActivate: () => startPolling(), // first subscriber arrived
onDeactivate: () => stopPolling(), // last subscriber left
});
const sub = userLoggedIn.subscribe(user => console.log(user.id));
userLoggedIn.publish({ id: 'u1' });
sub.unsubscribe();
The onActivate / onDeactivate hooks fire on the 0 → 1 and → 0 subscriber transitions, so producers can pay for upstream work only while someone is listening. topic.hasSubscribers lets a publisher skip building payloads nobody will see.
For a typed set of related events, createTopicGroup<EventMap>({ namespace }) creates topics lazily per event key and destroys them together:
import { createTopicGroup } from '@sigx/runtime-core';
const events = createTopicGroup<{ saved: { at: number }; cleared: void }>({ namespace: 'editor' });
events.topics.saved.subscribe(({ at }) => console.log('saved at', at));
events.topics.saved.publish({ at: Date.now() });
events.destroy();
Topics created with a namespace also register in the inspection registry at @sigx/runtime-core/inspect (getTopic, listTopics, subscribeTopics, onTopicCreated) — a string-addressed, Topic<unknown>-typed surface meant for devtools and diagnostics. Typed code should hold and share Topic<T> references; the strings are tooling metadata, not an app-level lookup mechanism.
Two-way binding with models
A Model<T> unifies reading (.value get), writing (.value set routes through the update handler), and forwarding (.binding). At the JSX level the runtime accepts a getter, a [obj, 'key'] tuple, or a forwarded model on the model prop; named models use model:name and emit update:name.
import { component, createModel, type Define } from '@sigx/runtime-core';
type FieldProps = Define.Model<string>;
const Field = component<FieldProps>(({ props }) => {
return () => <input value={props.model?.value} />;
});
// In a parent, build a model from a [source, key] tuple:
const state = signal({ name: '' });
const nameModel = createModel<string>([state, 'name'], (v) => {
state.name = v;
});
Use createModelFromBinding when you need to forward a full [obj, key, handler] binding tuple down to a child, and isModel to detect a model at runtime.
Lazy loading with Defer
lazy wraps a dynamic-import loader into a component loaded on first render. While the chunk loads the wrapper renders null; wrap it in <Defer> to show a fallback until every lazy chunk beneath it resolves. The lazy factory also exposes preload() so you can warm the chunk ahead of time.
import { lazy, Defer } from '@sigx/runtime-core';
const HeavyChart = lazy(() => import('./HeavyChart'));
const Dashboard = component(() => {
return () => (
<Defer fallback={<span>Loading chart…</span>}>
<HeavyChart />
</Defer>
);
});
// Warm the chunk on hover, before it is needed:
// onMouseEnter={() => HeavyChart.preload()}
<Defer> covers chunk loading only — a pending useData read renders through its own component's match(), not a wrapper. A lazy chunk that fails to load throws from render and routes to the nearest errorScope, then app.onError.
Loading data
For async data, reach for the value-first primitives — useData for reads and useAction for writes. useData(key, fetcher) auto-runs and returns a reactive AsyncState you render through match(); the fetcher receives the resolved key and an AbortSignal.
import { component, useData } from '@sigx/runtime-core';
const Editor = component(() => {
const libs = useData('editor:libs', async (key, { signal }) => ({
EditorView: (await import('@codemirror/view')).EditorView,
}));
return () => libs.match({
pending: () => <span>Loading…</span>,
error: (e) => <span>Failed: {e.message}</span>,
ready: () => <div>Editor ready</div>,
});
});
Every read has a key — it is the reactive trigger, the cache / SSR identity, and the fetcher's input. A static string key is SSR-transferable: it runs during SSR, serializes its value under the key, restores on hydration without refetching, and dedupes across components sharing the key. Pass a getter for a reactive key (() => ['user', id]), or { server: false } for a client-only read. useAction(fn) is the manual write counterpart (trigger with .run(input), resolves a settled RunResult), and useStream(key, source) streams progressive text (LLM tokens). See the Data Loading guide for the full story.
Handling errors
Call errorScope during setup to scope the calling component's own subtree — it is a function, not a wrapper component. Its fallback receives the error and a retry(), where retry is a real remount (the subtree unmounts and re-runs setup from a clean slate).
import { component, errorScope } from '@sigx/runtime-core';
const Safe = component(() => {
errorScope({
fallback: (error, retry) => (
<div>
{error.message}
<button onClick={retry}>Retry</button>
</div>
),
});
return () => <RiskyComponent />;
});
errorScope catches setup / render / reactive-re-render throws in the subtree, plus data errors bubbled by a match() with no error arm. It does not catch fetcher rejections (those are values on .error) or DOM event-handler throws — those route to the app-wide app.onError(fn) handler, which is also the last stop for anything no scope took. See Error handling for the full model.
Runtime errors are instances of SigxError, each carrying a stable code (such as SIGX102 for an async setup attempted on the client). Catch SigxError and branch on .code for targeted handling.
Directives
defineDirective marks a directive definition so it can be applied through the use:name prop syntax. Each lifecycle hook receives the element and a binding with the current value.
import { defineDirective } from '@sigx/runtime-core';
const tooltip = defineDirective<string>({
mounted(el, { value }) { el.title = value; },
updated(el, { value }) { el.title = value; },
unmounted(el) { el.title = ''; },
});
// Usage: <div use:tooltip="Helpful hint" />
Next steps
See the API reference for the complete typed surface, or the Installation guide for project setup.
