Messaging
SignalX provides a lightweight pub/sub messaging system with createTopic(). Topics enable decoupled communication between components without prop drilling or global state.
Creating Topics
import { createTopic } from 'sigx';
// Create a typed topic
const userUpdated = createTopic<{ id: number; name: string }>();
// Publish a message
userUpdated.publish({ id: 1, name: 'John' });
// Subscribe to messages
const subscription = userUpdated.subscribe((user) => {
console.log('User updated:', user.name);
});
// Unsubscribe when done
subscription.unsubscribe();
Delivery Guarantees
Topics have a strict behavior contract:
- Publishing is error-isolated. A throwing subscriber is logged via
console.errorand neither skips later subscribers nor propagates into the publisher. - Subscribing to a destroyed topic throws. A handler attached after
destroy()could never be cleaned up, so it is rejected loudly instead of silently leaking. - Publishing to a destroyed topic is a no-op, and
destroy()itself is idempotent.
Topics also expose live state — subscriberCount, hasSubscribers, and disposed — so publishers can skip building payloads nobody will see:
if (userUpdated.hasSubscribers) {
userUpdated.publish(buildExpensivePayload());
}
Auto-Cleanup in Components
When subscribing inside a component, subscriptions are automatically cleaned up on unmount:
import { component, createTopic } from 'sigx';
// Define topic outside component (shared)
const notifications = createTopic<{ message: string; type: 'info' | 'error' }>();
const NotificationDisplay = component(({ signal }) => {
const state = signal({ messages: [] as string[] });
// Auto-unsubscribes when component unmounts
notifications.subscribe((notification) => {
state.messages = [...state.messages, notification.message];
});
return () => (
<div class="notifications">
{state.messages.map(msg => (
<div class="notification">{msg}</div>
))}
</div>
);
});
// Any component can publish
const ActionButton = component(() => {
const handleClick = () => {
notifications.publish({
message: 'Action completed!',
type: 'info'
});
};
return () => (
<button onClick={handleClick}>Do Action</button>
);
});
Topic Options
Topics accept metadata and refCount lifecycle hooks:
const userEvents = createTopic<UserEvent>({
namespace: 'user', // tooling metadata
name: 'events', // tooling metadata
onActivate: () => startPolling(), // subscriberCount went 0 → 1
onDeactivate: () => stopPolling() // subscriberCount went back to 0
});
namespace and name are exposed read-only on the topic. Topics created with a namespace also register in the inspection registry for devtools; destroy() unregisters.
onActivate / onDeactivate implement the refCount pattern: pay for upstream work (polling, websocket connections, state watchers) only while someone is listening. onDeactivate fires on the last unsubscribe or on destroy. Hook errors are isolated, like subscriber errors.
Read-Only Subscribers
Use toSubscriber() to create a read-only version of a topic:
import { createTopic, toSubscriber } from 'sigx';
// Full topic (can publish and subscribe)
const internalEvents = createTopic<Event>();
// Read-only subscriber (can only subscribe)
export const events = toSubscriber(internalEvents);
// In another module:
events.subscribe((e) => console.log(e));
events.publish(); // Error: publish doesn't exist
This is useful for exposing topics from services while keeping publish control internal.
Typed Topic Groups
For a set of related events, createTopicGroup<EventMap>() gives mitt-level DX on the Topic primitive — one typed topic per event key, created lazily on first access and destroyed together:
import { createTopicGroup } from 'sigx';
// One typed topic per event key
const eventBus = createTopicGroup<{
userLogin: { userId: number };
userLogout: void;
cartAdd: { productId: number; quantity: number };
cartClear: void;
}>({ namespace: 'app' });
export default eventBus;
// Usage
import eventBus from './eventBus';
// Subscribe (payload typed per key)
eventBus.topics.userLogin.subscribe(({ userId }) => {
console.log('User logged in:', userId);
});
// Publish (type-checked)
eventBus.topics.userLogin.publish({ userId: 123 });
// Tear down every created topic at once
eventBus.destroy();
The namespace is applied to every created topic (each topic's name is its event key), so the whole group is discoverable by tooling. Accessing a key on a destroyed group throws.
Component Communication Example
import { component, createTopic, signal } from 'sigx';
// Shared topic for cart updates
const cartUpdated = createTopic<{ itemCount: number }>();
// Cart service
const cartItems = signal({ items: [] as string[] });
function addToCart(item: string) {
cartItems.items = [...cartItems.items, item];
cartUpdated.publish({ itemCount: cartItems.items.length });
}
// Header shows cart count
const Header = component(({ signal }) => {
const state = signal({ cartCount: 0 });
cartUpdated.subscribe(({ itemCount }) => {
state.cartCount = itemCount;
});
return () => (
<header>
<span>Cart ({state.cartCount})</span>
</header>
);
});
// Product can add to cart
const Product = component<{ name: string }>(({ props }) => {
return () => (
<div class="product">
<span>{props.name}</span>
<button onClick={() => addToCart(props.name)}>
Add to Cart
</button>
</div>
);
});
// App combines them
const App = component(() => {
return () => (
<div>
<Header />
<Product name="Widget" />
<Product name="Gadget" />
</div>
);
});
Using with Factories
Combine topics with factories for complex event handling:
import { defineFactory, createTopic } from 'sigx';
interface AnalyticsEvent {
event: string;
properties: Record<string, any>;
}
const useAnalytics = defineFactory((ctx) => {
const eventTopic = createTopic<AnalyticsEvent>();
const eventQueue: AnalyticsEvent[] = [];
let flushTimer: number | null = null;
const track = (event: string, properties: Record<string, any> = {}) => {
const analyticsEvent = { event, properties };
eventQueue.push(analyticsEvent);
eventTopic.publish(analyticsEvent);
scheduleFlush();
};
const scheduleFlush = () => {
if (flushTimer) return;
flushTimer = window.setTimeout(flush, 1000);
};
const flush = async () => {
if (eventQueue.length === 0) return;
const events = [...eventQueue];
eventQueue.length = 0;
await fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({ events })
});
flushTimer = null;
};
ctx.onDeactivated(() => {
if (flushTimer) {
clearTimeout(flushTimer);
flush(); // Flush remaining events
}
eventTopic.destroy();
});
return {
track,
onEvent: (handler: (event: AnalyticsEvent) => void) =>
eventTopic.subscribe(handler)
};
}, 'singleton');
// Usage
const analytics = useAnalytics();
analytics.track('page_view', { path: '/home' });
analytics.track('button_click', { button: 'signup' });
Inspecting Topics from Tooling
Topics created with a namespace register in a realm-global inspection registry, exposed through the dedicated @sigx/runtime-core/inspect entry. This is how devtools discover and trace topics — it is inspection-only: the registry is deliberately Topic<unknown>-typed, so it is not a substitute for sharing typed topic references. Typed code holds Topic<T> references; the strings are tooling metadata.
import { getTopic, listTopics, subscribeTopics, onTopicCreated } from '@sigx/runtime-core/inspect';
// Exact lookup
const topic = getTopic('app', 'userLogin');
// Wildcards match over `${namespace}.${name}`
listTopics('app.*');
listTopics('*.actions.*');
// Tap every matching topic — existing AND created later;
// one Subscription tears the whole tap down
const tap = subscribeTopics('app.*', (data, { namespace, name }) => {
console.log(`[${namespace}.${name}]`, data);
});
tap.unsubscribe();
// React to topics registering from now on
onTopicCreated((topic) => console.log('new topic:', topic.namespace, topic.name));
subscribeTopics and onTopicCreated auto-unsubscribe on component unmount when called inside a setup. This registry is what @sigx/devtools builds on to auto-discover stores and topics.
API Reference
createTopic<T>(options?)
Create a new pub/sub topic.
| Parameter | Type | Description |
|---|---|---|
options.namespace | string | Tooling metadata; topics with a namespace register in the inspection registry |
options.name | string | Tooling metadata |
options.onActivate | () => void | Called when subscriberCount transitions 0 → 1 |
options.onDeactivate | () => void | Called when subscriberCount transitions back to 0 |
Returns: Topic<T>
Topic<T>
| Member | Type | Description |
|---|---|---|
namespace | string | undefined (readonly) | Tooling metadata |
name | string | undefined (readonly) | Tooling metadata |
subscriberCount | number (readonly) | Current number of subscribers |
hasSubscribers | boolean (readonly) | subscriberCount > 0 |
disposed | boolean (readonly) | true after destroy() |
publish | (data: T) => void | Deliver to all subscribers; handler errors are isolated; no-op when disposed |
subscribe | (handler: (data: T) => void) => Subscription | Register a handler; throws if the topic is destroyed |
destroy | () => void | Remove all subscribers and unregister; idempotent |
createTopicGroup<EventMap>(options?)
Create a typed group of topics keyed by an event map.
| Parameter | Type | Description |
|---|---|---|
options.namespace | string | Applied to every created topic |
Returns: { topics: { [K in keyof EventMap]: Topic<EventMap[K]> }; destroy(): void } — topics are created lazily per key; destroy() destroys them all.
toSubscriber<T>(topic)
Create a read-only subscriber from a topic.
| Parameter | Type | Description |
|---|---|---|
topic | Topic<T> | The topic to wrap |
Returns: { subscribe: (handler: (data: T) => void) => Subscription }
Subscription
| Method | Type | Description |
|---|---|---|
unsubscribe | () => void | Remove the subscription |
@sigx/runtime-core/inspect
Inspection-only registry for tooling. Patterns use * wildcards matched over ${namespace}.${name}.
| Function | Signature | Description |
|---|---|---|
getTopic | (namespace: string, name: string) => Topic<unknown> | undefined | Exact lookup of a live registered topic |
listTopics | (pattern?: string) => Topic<unknown>[] | List live registered topics, optionally filtered |
subscribeTopics | (pattern: string, handler: (data, meta) => void) => Subscription | Subscribe to every matching topic, existing and future |
onTopicCreated | (handler: (topic: Topic<unknown>) => void) => Subscription | Handler for every topic registering from now on |
Best Practices
-
Define topics at module level for sharing:
TSX// events.ts export const userEvents = createTopic<UserEvent>(); -
Use TypeScript for type safety:
TSXconst topic = createTopic<{ id: number; name: string }>(); topic.publish({ id: 1, name: 'Test' }); // Type-checked -
Clean up manual subscriptions:
TSXconst sub = topic.subscribe(handler); // Later: sub.unsubscribe(); -
Use toSubscriber for encapsulation:
TSX// Internal: full control const _events = createTopic<Event>(); // External: read-only export const events = toSubscriber(_events); -
Destroy topics when no longer needed (and remember: subscribing after destroy throws):
TSXctx.onDeactivated(() => { topic.destroy(); }); -
Gate expensive work on listeners:
TSXconst topic = createTopic<Frame>({ onActivate: () => startCapture(), onDeactivate: () => stopCapture() }); if (topic.hasSubscribers) topic.publish(captureFrame()); -
Keep the inspection registry for tooling. Share typed
Topic<T>references between modules; reach for@sigx/runtime-core/inspectonly in devtools/diagnostic code.
When to Use Topics vs Props
| Use Props When | Use Topics When |
|---|---|
| Parent-child communication | Sibling communication |
| Data flows down | Events flow laterally |
| Tight coupling is fine | Loose coupling needed |
| Simple component trees | Complex/deep trees |
Next Steps
- Factories - Injectable services
- Components - Component patterns
- App & Plugins - Application structure
