TypeScript Channels Pattern
What It Does
createChannel creates a pub-sub mechanism that links a websocket listener to a
component, allowing updates triggered by network events to propagate to the UI
without tight coupling between the two.
The Implementation
/**
* Creates a channel/link between a websocket listener and a component.
* This allows you to keep listeners separate from components but update
* a component's rune whenever a listener event fires.
*
* This is good for simply linkages between a websocket event and a page
* update or rerender.
*/
export type Unsubscribe = () => void;
export type Channel<T> = {
subscribe(fn: (v: T) => void): Unsubscribe;
next(value: T): void;
};
export function createChannel<T>(): Channel<T> {
const subs = new Set<(v: T) => void>();
return {
subscribe(fn: (v: T) => void): Unsubscribe {
subs.add(fn);
return () => subs.delete(fn);
},
next(value: T): void {
for (const fn of subs) fn(value);
},
};
}How It’s Used
1. Create a channel with your data type
// Create a channel for tracking websocket messages
const messageChannel = createChannel<Message>();
// Create a channel for connection status
const connectionChannel = createChannel<ConnectionStatus>();2. Subscribe in a React component
function MessageDisplay() {
const [messages, setMessages] = useState<Message[]>([]);
const unsubscribe = messageChannel.subscribe((msg) => {
setMessages((prev) => [...prev, msg]);
});
// Cleanup on unmount - Note the inner function!
// useEffect accepts an effect function. If that function returns another function,
// React treats the inner function as the cleanup for that effect.
// • Mount: React runs the outer function (the effect body).
// • Unmount: React runs the returned function (the cleanup).
useEffect(() => () => unsubscribe(), []);
// Equivalent, more explicit: the *effect* returns a *cleanup* function; React runs it on unmount
// (and before re-running the effect if deps change — here `[]` means mount once, cleanup on unmount).
// useEffect(() => {
// return () => {
// unsubscribe();
// };
// }, []);
return <div>{messages.map((m) => <div key={m.id}>{m.text}</div>)}</div>;
}3. Emit from your websocket handler
// WebSocket connection established
ws.onopen = () => {
connectionChannel.next({ type: "connected" });
};
// New message received
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
messageChannel.next(msg);
};
// Connection closed
ws.onclose = () => {
connectionChannel.next({ type: "disconnected" });
};4. Benefits
Decoupling
- WebSocket logic knows nothing about React components
- Components don’t need to import or know about the websocket
- Easy to swap implementations (e.g., change backend without touching UI)
Testability
- Components test with a mock channel instead of real websocket
- Handlers test without starting a server
Reusability
- Same channel used by multiple components
- Same handler used with different channels
Pattern: Channel Listener Pattern
┌─────────────┐ Channel Subscribe ┌─────────────┐
│ WebSocket │ ────────────────────────────► │ UI │
│ Handler │ │ Component │
└─────────────┘ └─────────────┘
│ │
▼ ▼
.next(message) subscribe(fn)
The pattern decouples event sources from event consumers using a pub-sub channel.