Read OSS

Channels and Events: The Communication Protocol That Holds Storybook Together

Intermediate

Prerequisites

  • Article 1: Architecture Overview
  • Understanding of the Event Emitter pattern
  • Basic knowledge of PostMessage and WebSocket APIs

Channels and Events: The Communication Protocol That Holds Storybook Together

In Part 1, we established that Storybook runs across three isolated environments: a Node.js server, a Manager UI, and a Preview iframe. In Part 2, we saw how the preset system composes configuration on the server. But once the browser loads, how do these environments actually talk to each other? When you click a story in the sidebar, how does the preview iframe know to render it?

The answer is Storybook's channel system — an event emitter with pluggable transports that acts as the communication backbone. With over 100 distinct event types, it forms a protocol rich enough to handle everything from story selection to args mutation to telemetry error reporting.

The Channel Class and Transport Abstraction

At its core, the Channel class is a browser-compatible event emitter that delegates message delivery to one or more transport objects:

code/core/src/channels/main.ts#L22-L147

classDiagram
    class Channel {
        -sender: string
        -events: EventsKeyValue
        -data: Record~string, any~
        -transports: ChannelTransport[]
        +emit(eventName, ...args)
        +on(eventName, listener)
        +once(eventName, listener)
        +off(eventName, listener)
        +last(eventName): any
        +hasTransport: boolean
    }
    class ChannelTransport {
        <<interface>>
        +send(event, options)
        +setHandler(handler)
    }
    class PostMessageTransport {
        +send(event, options)
        +setHandler(handler)
    }
    class WebsocketTransport {
        +send(event, options)
        +setHandler(handler)
    }
    Channel --> "*" ChannelTransport
    ChannelTransport <|.. PostMessageTransport
    ChannelTransport <|.. WebsocketTransport

Each Channel instance generates a random sender ID (line 25). When emit() is called, it creates a ChannelEvent with the event type, arguments, and sender ID, then distributes it to all transports and handles it locally. This dual dispatch — to transports and to local listeners — means that an event emitted by the Manager reaches both the Preview (via transport) and local Manager listeners.

The last() method (line 84) is a subtle but useful feature: it caches the most recent args for each event type. This enables late-joining listeners to retrieve the last known state without waiting for the next emission.

PostMessage vs WebSocket Transports

The createBrowserChannel() factory assembles the appropriate transports based on the environment:

code/core/src/channels/index.ts#L32-L51

flowchart TB
    CBC["createBrowserChannel({ page })"] --> PM["PostMessageTransport"]
    CBC --> DevCheck{"CONFIG_TYPE === 'DEVELOPMENT'?"}
    DevCheck -->|Yes| WS["WebsocketTransport"]
    DevCheck -->|No| NoWS["(no WebSocket)"]

    PM --> Channel["Channel instance"]
    WS --> Channel

    subgraph "Communication Paths"
        direction LR
        ManagerFrame["Manager<br>(parent frame)"] <-->|PostMessage| PreviewFrame["Preview<br>(iframe)"]
        ManagerFrame <-->|WebSocket| Server["Node Server"]
        PreviewFrame <-->|WebSocket| Server
    end

PostMessage is always present. It handles the iframe-to-parent communication path. Both the Manager and Preview create their own channels with page: 'manager' or page: 'preview' respectively.

WebSocket is only added in development mode (line 35). It connects to the server's WebSocket endpoint at /storybook-server-channel?token=<wsToken>. The token is generated server-side and embedded in the HTML page, preventing unauthorized connections.

In a production (static) build, there is no server, so only PostMessage is available. This means server-dependent features (file search, story creation, etc.) are dev-only.

Tip: If you're building an addon that needs server-side functionality, always check if the WebSocket transport is available before relying on server events. In a static deployment, those events will never arrive.

The Event Vocabulary

The core-events/index.ts file defines the complete event vocabulary:

code/core/src/core-events/index.ts#L1-L103

These events fall into several categories:

flowchart TD
    Events["Core Events (~50 named)"] --> Lifecycle["Story Lifecycle"]
    Events --> State["State Mutation"]
    Events --> UI["UI Coordination"]
    Events --> ServerFeatures["Server Features"]

    Lifecycle --> L1["STORY_SPECIFIED"]
    Lifecycle --> L2["STORY_PREPARED"]
    Lifecycle --> L3["STORY_RENDERED"]
    Lifecycle --> L4["STORY_FINISHED"]
    Lifecycle --> L5["STORY_RENDER_PHASE_CHANGED"]

    State --> S1["UPDATE_STORY_ARGS / STORY_ARGS_UPDATED"]
    State --> S2["UPDATE_GLOBALS / GLOBALS_UPDATED"]
    State --> S3["SET_INDEX"]

    UI --> U1["SET_CURRENT_STORY / CURRENT_STORY_WAS_SET"]
    UI --> U2["SELECT_STORY"]
    UI --> U3["PREVIEW_KEYDOWN"]

    ServerFeatures --> SF1["FILE_COMPONENT_SEARCH_*"]
    ServerFeatures --> SF2["CREATE_NEW_STORYFILE_*"]
    ServerFeatures --> SF3["SAVE_STORY_*"]
    ServerFeatures --> SF4["OPEN_IN_EDITOR_*"]

A naming convention emerges: events that request an action use imperative names (SET_CURRENT_STORY, UPDATE_STORY_ARGS), while events that report a completed action use past tense (CURRENT_STORY_WAS_SET, STORY_ARGS_UPDATED). Server features use a request/response pair pattern (FILE_COMPONENT_SEARCH_REQUEST / FILE_COMPONENT_SEARCH_RESPONSE).

Event Flow Trace: Selecting a Story

Let's trace what happens when a user clicks "Button/Primary" in the sidebar:

sequenceDiagram
    participant User
    participant Sidebar as Manager: Sidebar
    participant API as Manager: stories module
    participant Channel as Channel (PostMessage)
    participant PWS as Preview: PreviewWithSelection
    participant SR as Preview: StoryRender

    User->>Sidebar: Click "Button/Primary"
    Sidebar->>API: api.selectStory('button--primary')
    API->>API: Navigate URL, update state
    API->>Channel: emit(SET_CURRENT_STORY, { storyId, viewMode })
    Channel->>PWS: onSetCurrentStory({ storyId, viewMode })
    PWS->>PWS: Update selection, create StoryRender
    PWS->>SR: render.prepare()
    SR->>SR: Load story from StoryStore
    SR->>Channel: emit(STORY_PREPARED, { id, parameters, ... })
    Channel->>API: Handle STORY_PREPARED
    API->>API: Update story data in state
    SR->>SR: renderToElement(canvasElement)
    SR->>Channel: emit(STORY_RENDERED, storyId)
    Channel->>API: Handle STORY_RENDERED

The key insight is that the Manager never directly tells the Preview how to render. It sends a declarative SET_CURRENT_STORY event with a story ID, and the Preview independently loads, prepares, and renders the story. Status updates flow back through the channel so the Manager can update its UI (showing loading states, parameters in addon panels, etc.).

This declarative pattern means the Manager and Preview can evolve independently. The Preview doesn't need to know about the sidebar; the Manager doesn't need to know about rendering internals.

The Server Channel

Server-to-browser communication uses the WebSocket transport for features that require filesystem access:

The common preset initializes all server channel handlers:

code/core/src/core-server/presets/common-preset.ts#L271-L288

export const experimental_serverChannel = async (channel, options) => {
  initializeChecklist();
  initializeWhatsNew(channel, options, coreOptions);
  initializeSaveStory(channel, options, coreOptions);
  initFileSearchChannel(channel, options, coreOptions);
  initCreateNewStoryChannel(channel, options, coreOptions);
  initGhostStoriesChannel(channel, options, coreOptions);
  initOpenInEditorChannel(channel, options, coreOptions);
  initTelemetryChannel(channel, options);
  return channel;
};

Each init* function registers listeners for request events and emits response events. For example, initFileSearchChannel listens for FILE_COMPONENT_SEARCH_REQUEST, searches the filesystem using the project's glob patterns, and emits FILE_COMPONENT_SEARCH_RESPONSE with the results.

The server channel also handles the STORY_INDEX_INVALIDATED event — when files change on disk, the server invalidates its story index cache and broadcasts the event so both Manager and Preview can refetch.

Universal Store: Cross-Environment State Sync

Built on top of the channel system is the UniversalStore — a newer abstraction for synchronizing state across environments:

code/core/src/shared/universal-store/index.ts#L82-L120

The UniversalStore follows a leader/follower pattern:

sequenceDiagram
    participant Leader as Leader (Server)
    participant Channel as Channel
    participant Follower1 as Follower (Manager)
    participant Follower2 as Follower (Preview)

    Leader->>Channel: LEADER_CREATED
    Follower1->>Channel: FOLLOWER_CREATED
    Follower1->>Channel: EXISTING_STATE_REQUEST
    Leader->>Channel: EXISTING_STATE_RESPONSE { state }
    Follower1->>Follower1: Set state, resolve syncing

    Follower2->>Channel: FOLLOWER_CREATED
    Follower2->>Channel: EXISTING_STATE_REQUEST
    Leader->>Channel: EXISTING_STATE_RESPONSE { state }
    Follower2->>Follower2: Set state, resolve syncing

    Note over Leader,Follower2: Normal operation
    Follower1->>Channel: SET_STATE { newState }
    Leader->>Leader: Update local state
    Leader->>Channel: Forward SET_STATE to other followers
    Follower2->>Follower2: Update local state

The leader (typically on the server) owns the authoritative state. Followers in the Manager and Preview automatically sync when created — they send an EXISTING_STATE_REQUEST and wait up to 1 second for a response. If no leader responds, the follower enters an error state.

State changes from any follower flow through the leader, which forwards them to all other followers. This hub-and-spoke topology ensures consistency without requiring direct follower-to-follower communication.

The UniversalStore.__prepare() static method is called during channel creation (line 45-48 of channels/index.ts), binding the store to the channel and setting the environment type. This happens automatically — store instances created before the channel is ready will queue up and initialize when preparation completes.

Tip: The Universal Store is designed for relatively small, frequently-updated state (think feature flags, toolbar settings). For large datasets like the story index, the older event-based pattern with SET_INDEX remains more appropriate.

What's Next

With the channel system understood, we have the complete communication layer: presets compose configuration at build time, channels shuttle events at runtime. In the next article, we'll follow the events into the Preview iframe and trace the complete rendering pipeline — from generated Vite virtual modules through CSF processing, config composition, the StoryRender lifecycle, and framework renderer integration.