Channels and Events: The Communication Protocol That Holds Storybook Together
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_INDEXremains 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.