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#

TSX
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.error and 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:

TSX
if (userUpdated.hasSubscribers) {
    userUpdated.publish(buildExpensivePayload());
}

Auto-Cleanup in Components#

When subscribing inside a component, subscriptions are automatically cleaned up on unmount:

TSX
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:

TSX
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:

TSX
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:

TSX
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#

TSX
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:

TSX
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.

TSX
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.

ParameterTypeDescription
options.namespacestringTooling metadata; topics with a namespace register in the inspection registry
options.namestringTooling metadata
options.onActivate() => voidCalled when subscriberCount transitions 0 → 1
options.onDeactivate() => voidCalled when subscriberCount transitions back to 0

Returns: Topic<T>

Topic<T>#

MemberTypeDescription
namespacestring | undefined (readonly)Tooling metadata
namestring | undefined (readonly)Tooling metadata
subscriberCountnumber (readonly)Current number of subscribers
hasSubscribersboolean (readonly)subscriberCount > 0
disposedboolean (readonly)true after destroy()
publish(data: T) => voidDeliver to all subscribers; handler errors are isolated; no-op when disposed
subscribe(handler: (data: T) => void) => SubscriptionRegister a handler; throws if the topic is destroyed
destroy() => voidRemove all subscribers and unregister; idempotent

createTopicGroup<EventMap>(options?)#

Create a typed group of topics keyed by an event map.

ParameterTypeDescription
options.namespacestringApplied 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.

ParameterTypeDescription
topicTopic<T>The topic to wrap

Returns: { subscribe: (handler: (data: T) => void) => Subscription }

Subscription#

MethodTypeDescription
unsubscribe() => voidRemove the subscription

@sigx/runtime-core/inspect#

Inspection-only registry for tooling. Patterns use * wildcards matched over ${namespace}.${name}.

FunctionSignatureDescription
getTopic(namespace: string, name: string) => Topic<unknown> | undefinedExact lookup of a live registered topic
listTopics(pattern?: string) => Topic<unknown>[]List live registered topics, optionally filtered
subscribeTopics(pattern: string, handler: (data, meta) => void) => SubscriptionSubscribe to every matching topic, existing and future
onTopicCreated(handler: (topic: Topic<unknown>) => void) => SubscriptionHandler for every topic registering from now on

Best Practices#

  1. Define topics at module level for sharing:

    TSX
    // events.ts
    export const userEvents = createTopic<UserEvent>();
  2. Use TypeScript for type safety:

    TSX
    const topic = createTopic<{ id: number; name: string }>();
    topic.publish({ id: 1, name: 'Test' }); // Type-checked
  3. Clean up manual subscriptions:

    TSX
    const sub = topic.subscribe(handler);
    // Later:
    sub.unsubscribe();
  4. Use toSubscriber for encapsulation:

    TSX
    // Internal: full control
    const _events = createTopic<Event>();
    // External: read-only
    export const events = toSubscriber(_events);
  5. Destroy topics when no longer needed (and remember: subscribing after destroy throws):

    TSX
    ctx.onDeactivated(() => {
        topic.destroy();
    });
  6. Gate expensive work on listeners:

    TSX
    const topic = createTopic<Frame>({
        onActivate: () => startCapture(),
        onDeactivate: () => stopCapture()
    });
    if (topic.hasSubscribers) topic.publish(captureFrame());
  7. Keep the inspection registry for tooling. Share typed Topic<T> references between modules; reach for @sigx/runtime-core/inspect only in devtools/diagnostic code.

When to Use Topics vs Props#

Use Props WhenUse Topics When
Parent-child communicationSibling communication
Data flows downEvents flow laterally
Tight coupling is fineLoose coupling needed
Simple component treesComplex/deep trees

Next Steps#