
Skill
devtools-event-client
implement typed event clients for libraries
Description
Create typed EventClient for a library. Define event maps with typed payloads, pluginId auto-prepend namespacing, emit()/on()/onAll()/onAllPluginEvents() API. Connection lifecycle (5 retries, 300ms), event queuing, enabled/disabled state, SSR fallbacks, singleton pattern. Unique pluginId requirement to avoid event collisions.
SKILL.md
devtools-event-client
Typed event emitter/listener that connects application code to TanStack Devtools panels. Framework-agnostic. Works in React, Vue, Solid, Preact, and vanilla JS.
Setup
Install the package:
npm i @tanstack/devtools-event-client
The package has two entry points — the root export (the real client in development, a no-op tree-shaken out of production), and the /production subpath (always the real client, for libraries that want devtools events in production):
import { EventClient } from '@tanstack/devtools-event-client'
Constructor Options
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
pluginId | string | Yes | -- | Identifies this plugin in the event system. Must be unique across all plugins. |
debug | boolean | No | false | Enable verbose console logging prefixed with [tanstack-devtools:{pluginId}-plugin]. |
enabled | boolean | No | true | When false, emit() is a no-op and on() returns a no-op cleanup function. |
reconnectEveryMs | number | No | 300 | Interval in ms between connection retry attempts (max 5 retries). |
Core Patterns
1. Define an Event Map and Create a Singleton Client
Define a TypeScript type mapping event suffixes to payload types. Extend EventClient and export a single instance at module level.
import { EventClient } from '@tanstack/devtools-event-client'
type StoreEvents = {
'state-changed': { storeName: string; state: unknown; timestamp: number }
'action-dispatched': { storeName: string; action: string; payload: unknown }
reset: void
}
class StoreInspectorClient extends EventClient<StoreEvents> {
constructor() {
super({ pluginId: 'store-inspector' })
}
}
// Module-level singleton -- one instance per plugin
export const storeInspector = new StoreInspectorClient()
Event map keys are suffixes only. The pluginId is prepended automatically. With pluginId: 'store-inspector' and key 'state-changed', the fully qualified event on the bus is 'store-inspector:state-changed'.
2. Emit Events
Call emit(suffix, payload) from library code. Pass only the suffix.
function dispatch(action: string, payload: unknown) {
state = reducer(state, action, payload)
storeInspector.emit('state-changed', {
storeName: 'main',
state,
timestamp: Date.now(),
})
storeInspector.emit('action-dispatched', {
storeName: 'main',
action,
payload,
})
}
If the bus is not connected yet, events are queued in memory and flushed once the connection succeeds. If the connection fails after 5 retries (1.5s at default settings), the client gives up and subsequent emit() calls are silently dropped.
Connection to the bus is initiated lazily on the first emit() call, not on construction or on().
3. Listen to Events
All listener methods return a cleanup function.
on(suffix, callback) -- listen to a specific event from this plugin:
const cleanup = storeInspector.on('state-changed', (event) => {
// event.type === 'store-inspector:state-changed'
// event.payload === { storeName: string; state: unknown; timestamp: number }
// event.pluginId === 'store-inspector'
console.log(event.payload.state)
})
// Stop listening
cleanup()
on(suffix, callback, { withEventTarget: true }) -- also register on an internal EventTarget so events emitted and listened to on the same client instance are delivered immediately without going through the global bus:
const cleanup = storeInspector.on(
'state-changed',
(event) => {
console.log(event.payload.state)
},
{ withEventTarget: true },
)
onAll(callback) -- listen to all events from all plugins:
const cleanup = storeInspector.onAll((event) => {
console.log(event.type, event.payload)
})
onAllPluginEvents(callback) -- listen to all events from this plugin only (filtered by pluginId):
const cleanup = storeInspector.onAllPluginEvents((event) => {
// Only fires when event.pluginId === 'store-inspector'
console.log(event.type, event.payload)
})
4. Connection Lifecycle and Disabling
The connection lifecycle is:
- First
emit()dispatchestanstack-connectand starts a retry loop. - Retries every
reconnectEveryMs(default 300ms), up to 5 attempts. - On
tanstack-connect-success, queued events are flushed in order. - After 5 failed retries,
failedToConnectis set permanently. All subsequentemit()calls are silently dropped (not queued).
To disable the client entirely (e.g., in production):
class StoreInspectorClient extends EventClient<StoreEvents> {
constructor() {
super({
pluginId: 'store-inspector',
enabled: process.env.NODE_ENV !== 'production',
})
}
}
When enabled is false, emit() is a no-op and on()/onAll()/onAllPluginEvents() return no-op cleanup functions.
Common Mistakes
1. Including pluginId prefix in event names (CRITICAL)
EventClient auto-prepends the pluginId to all event names. Including the prefix manually produces a double-prefixed event name that nothing will match.
Wrong:
storeInspector.emit('store-inspector:state-changed', data)
// Dispatches 'store-inspector:store-inspector:state-changed'
Correct:
storeInspector.emit('state-changed', data)
// Dispatches 'store-inspector:state-changed'
This applies to on() as well. Pass only the suffix.
2. Creating multiple EventClient instances per plugin (CRITICAL)
Each EventClient instance manages its own connection, event queue, and listeners independently. Creating multiple instances for the same plugin causes duplicate handlers, multiple connection attempts, and unpredictable event delivery.
Wrong:
function MyComponent() {
// New instance on every render
const client = new StoreInspectorClient()
client.emit('state-changed', data)
}
Correct:
// store-inspector-client.ts
export const storeInspector = new StoreInspectorClient()
// MyComponent.tsx
import { storeInspector } from './store-inspector-client'
function MyComponent() {
storeInspector.emit('state-changed', data)
}
3. Non-unique pluginId causing event collisions (CRITICAL)
Two plugins with the same pluginId share an event namespace. Events emitted by one are received by listeners on the other. Choose a unique, descriptive pluginId (e.g., 'my-org-store-inspector' rather than 'store').
4. Not realizing events drop after 5 failed retries (HIGH)
After 5 retries (1.5s at default reconnectEveryMs: 300), failedToConnect is set permanently. Subsequent emit() calls are silently dropped -- they are not queued and will never be delivered, even if the bus becomes available later.
If you need events to survive longer startup delays, increase reconnectEveryMs:
super({ pluginId: 'store-inspector', reconnectEveryMs: 1000 })
// 5 retries * 1000ms = 5s window
There is no way to increase the retry count (hardcoded to 5).
5. Expecting connection on construction or on() (HIGH)
The connection to the event bus is initiated lazily on the first emit() call. Calling on() alone does not trigger a connection. If your panel calls on() but the library side never calls emit(), the client never connects to the bus.
This means if you only listen (no emitting), the on() handler still works for events dispatched directly on the global event target, but the connection handshake (tanstack-connect / tanstack-connect-success) never runs.
6. Using non-serializable payloads (HIGH)
When the server event bus is enabled, events are serialized via JSON for transport over WebSocket/SSE/BroadcastChannel. Payloads containing functions, DOM nodes, class instances, Map/Set, or circular references will fail silently or lose data.
Wrong:
storeInspector.emit('state-changed', {
storeName: 'main',
state,
callback: () => {}, // Function -- not serializable
element: document.body, // DOM node -- not serializable
})
Correct:
storeInspector.emit('state-changed', {
storeName: 'main',
state: JSON.parse(JSON.stringify(state)), // Ensure serializable
timestamp: Date.now(),
})
7. Forgetting the root export no-ops in production (HIGH)
The root import of @tanstack/devtools-event-client resolves to a no-op
when process.env.NODE_ENV !== 'development', and the real client is
tree-shaken out of production bundles. This is the default and what you want for
most libraries — your emit() calls cost nothing in production.
"Outside development" includes when NODE_ENV is unset — common in plain Node scripts, some SSR dev servers, and test runners — so the root import resolves to the no-op there too. Set NODE_ENV=development, or use the /production subpath, to get the real client in those contexts.
// dev: real client — production: no-op, removed from the bundle
import { EventClient } from '@tanstack/devtools-event-client'
If you are publishing an open-source library and deliberately want devtools
events to keep working in production, import from the /production subpath,
which always ships the real client:
import { EventClient } from '@tanstack/devtools-event-client/production'
The enabled constructor option still works for fine-grained runtime control,
but you no longer need to guard emit() calls manually for bundle size — the
root export handles that for you.
See Also
devtools-instrumentation-- after creating a client, instrument library code with strategic emissionsdevtools-plugin-panel-- the client emits events, the panel listens using the same event mapdevtools-bidirectional-- two-way communication between panel and application using the same EventClient
More skills from the devtools repository
View all 9 skillsdevtools-app-setup
configure TanStack Devtools for applications
Jul 16DebuggingFrontendTanStackdevtools-bidirectional
implement bidirectional devtools communication
Jul 16DebuggingObservabilityTanStackdevtools-framework-adapters
create framework adapters for devtools
Jul 16EngineeringPreactReactSolidJS +2devtools-instrumentation
instrument library code for devtools
Jul 16DebuggingEngineeringObservabilityTanStackdevtools-marketplace
publish plugins to TanStack Devtools Marketplace
Jul 16npmTanStackdevtools-plugin-panel
build TanStack devtools panels
Jul 16FrontendObservabilityTanStackUI Components
More from TanStack
View publisheraggregation
perform data aggregation in TanStack Table
table
Jul 26Data AnalysisFrontendTanStackapi-not-found
diagnose TanStack Table API errors
table
Jul 26DebuggingFrontendTanStackcell-selection
select rectangular cell ranges in tables
table
Jul 26Data AnalysisTanStackUI Componentsclient-vs-server
manage TanStack Table data pipelines
table
Jul 26Data PipelineFrontendPerformanceTanStackcolumn-faceting
build faceted filter UIs
table
Jul 26Data VisualizationFrontendTanStackcolumn-filtering
implement column filtering in TanStack Table
table
Jul 26Data AnalysisFrontendTanStack