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
BroadcastChannelfor 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:
UploadEngineemits internal eventsUploadContextsubscribes toupload, detectsUPLOAD_COMPLETED, and publishesupload:completedUploadListenersubscribes toupload:completed, processes the payload, and publishesupload:domainProcessingCompleted-{id}- The originating component listens for its specific
domainProcessingCompletedsignal
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
- Add the topic string to
SignalTopicTypeinsrc/lib/internal/Signal.ts - Use
Signal.publish<PayloadType>(topic, data)at the source - Use
Signal.subscribe<PayloadType>(topic, callback)at the consumer - Call
Signal.unsubscribe(topic)in cleanup (typically auseEffectreturn)
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
useEffectowns 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
BroadcastChannelmechanism - Dynamic template literal topics — enables per-entity targeting (e.g., per-upload completion) without abandoning type safety