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.