Signal

Prev Next

Signal

Signal is a typed, in-process pub/sub transport for decoupled communication between components, modules, and services within the same browser tab.

It wraps PubSubJS with a type-safe topic registry and a static API.

Scope and Responsibility

Signal is responsible for:

  • Publishing domain events within the browser tab
  • Routing events to subscribers by topic
  • Enforcing topic-level type safety via SignalTopicType

Signal is not responsible for:

  • Cross-tab communication (use BroadcastChannel for that)
  • Persisting events
  • Guaranteeing delivery order
  • Network communication
  • Business logic or orchestration

API

import { Signal } from '@/lib/internal/Signal';

// Subscribe — returns a token for targeted unsubscribe
const token = Signal.subscribe<boolean>('loading', (value) => {
  setLoading(value ?? false);
});

// Publish — fires event to all subscribers of the topic
Signal.publish<boolean>('loading', true);

// Unsubscribe — removes all subscribers for the topic
Signal.unsubscribe('loading');
Method Signature Returns
subscribe subscribe<T>(topic, callback) → string PubSubJS subscription token
unsubscribe unsubscribe(topic) → void
publish publish<T>(topic, data?) → void

All three methods are static. There is no instance state.

Topic Registry

Topics are defined as the SignalTopicType union. Adding a new topic requires updating this type — arbitrary strings are rejected at compile time.

Topic Domain Payload Description
loading UI boolean Global loading spinner state
notification UI Notification trigger
upload Upload { event: UploadEvent } Upload state change (triggers sync)
upload:completed Upload UploadSessionSnapshot Upload finished — dispatches to UploadListener
upload:domainProcessingCompleted-${id} Upload UploadDomainProcessedEvent Domain processing done for a specific upload
content:add Content Content created
content:edit Content Content updated
content:tab-change Content Content tab switched
admin::apikey_edit Admin API key edited
admin::encoding_edit Admin Encoding config edited
admin::user_edit Admin System user edited
zipcode-group:edit Monetization Zipcode group edited
zipcode-group:delete Monetization Zipcode group deleted
license-group:edit Monetization License group edited
plan-version Monetization string (planId) Plan version action triggered
plan-country:edit Monetization Plan country invariant edited
plan-country:delete Monetization Plan country invariant deleted
customer-support:add-user Customer Support User added
customer-support:delete-device Customer Support Device deleted
customer-support:refresh-billing-history Customer Support Billing history refresh requested
auth-user-reset-password-otp-verified Auth boolean OTP verified during password reset
auth-user-reset-password-change-success Auth boolean Password change completed

Dynamic topics use template literal types (e.g., upload:domainProcessingCompleted-${string}) for per-entity targeting.

Execution Model

Publisher ──publish(topic, data)──► PubSubJS ──callback(data)──► Subscriber(s)
  • Delivery is synchronous within PubSubJS internals
  • Multiple subscribers on the same topic all receive the event
  • unsubscribe(topic) removes all subscribers for that topic — not a single one
  • No replay, buffering, or persistence — if no subscriber is listening, the event is dropped

Common Patterns

Global Loading State

ClientWrapper subscribes to loading to drive a top-level progress bar. Auth flows publish loading to coordinate UI state without prop drilling:

// Subscribe (ClientWrapper)
Signal.subscribe<boolean>('loading', (value) => {
  setLoading(value ?? false);
});

// Publish (useAuth)
setLoading(() => {
  Signal.publish('loading', true);
  return true;
});

Upload Pipeline

The upload system uses Signal as its event bus:

  1. UploadEngine emits internal events
  2. UploadContext subscribes to upload, detects UPLOAD_COMPLETED, and publishes upload:completed
  3. UploadListener subscribes to upload:completed, processes the payload, and publishes upload:domainProcessingCompleted-{id}
  4. The originating component listens for its specific domainProcessingCompleted signal

Entity Refresh

Modules publish edit/delete signals to tell sibling components to re-fetch:

// After editing a plan country
Signal.publish('plan-country:edit');

// Subscriber re-fetches data
Signal.subscribe('plan-country:edit', () => {
  refetch();
});

Adding a New Topic

  1. Add the topic string to SignalTopicType in src/lib/internal/Signal.ts
  2. Use Signal.publish<PayloadType>(topic, data) at the source
  3. Use Signal.subscribe<PayloadType>(topic, callback) at the consumer
  4. Call Signal.unsubscribe(topic) in cleanup (typically a useEffect return)

Design Decisions

  • Static class wrapping PubSubJS — avoids instance management while providing a typed API surface over a well-tested pub/sub library
  • Typed topic union — prevents typos and undocumented topics; new topics require a code change in the source of truth
  • Topic-level unsubscribe only — matches the usage pattern where each useEffect owns a topic subscription and cleans up on unmount
  • No cross-tab delivery — Signal is for in-tab coordination; cross-tab concerns (e.g., logout broadcast) use a separate BroadcastChannel mechanism
  • Dynamic template literal topics — enables per-entity targeting (e.g., per-upload completion) without abandoning type safety