packages/event-bus-client/skills/devtools-event-client/SKILL.md
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.
npx skillsauth add tanstack/devtools devtools-event-clientInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Typed event emitter/listener that connects application code to TanStack Devtools panels. Framework-agnostic. Works in React, Vue, Solid, Preact, and vanilla JS.
Install the package:
npm i @tanstack/devtools-event-client
The package exports a single class:
import { EventClient } from '@tanstack/devtools-event-client'
| 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). |
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'.
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().
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)
})
The connection lifecycle is:
emit() dispatches tanstack-connect and starts a retry loop.reconnectEveryMs (default 300ms), up to 5 attempts.tanstack-connect-success, queued events are flushed in order.failedToConnect is set permanently. All subsequent emit() 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.
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.
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)
}
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').
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).
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.
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(),
})
The Vite plugin strips adapter imports (e.g., @tanstack/react-devtools) from production builds, but it does NOT strip @tanstack/devtools-event-client imports or emit() calls. Library authors must guard emit calls themselves.
Options:
Option A: Use the enabled constructor option:
super({
pluginId: 'store-inspector',
enabled: process.env.NODE_ENV !== 'production',
})
Option B: Conditional guard at the call site:
if (process.env.NODE_ENV !== 'production') {
storeInspector.emit('state-changed', data)
}
When enabled is false, emit() returns immediately (no event creation, no queuing, no connection attempt). This is the preferred approach.
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 EventClienttools
Handle devtools in production vs development. removeDevtoolsOnBuild, devDependency vs regular dependency, conditional imports, NoOp plugin variants for tree-shaking, non-Vite production exclusion patterns.
tools
Publish plugin to npm and submit to TanStack Devtools Marketplace. PluginMetadata registry format, plugin-registry.ts, pluginImport (importName, type), requires (packageName, minVersion), framework tagging, multi-framework submissions, featured plugins.
tools
Configure @tanstack/devtools-vite for source inspection (data-tsd-source, inspectHotkey, ignore patterns), console piping (client-to-server, server-to-client, levels), enhanced logging, server event bus (port, host, HTTPS), production stripping (removeDevtoolsOnBuild), editor integration (launch-editor, custom editor.open). Must be FIRST plugin in Vite config. Vite ^6 || ^7 only.
tools
Analyze library codebase for critical architecture and debugging points, add strategic event emissions. Identify middleware boundaries, state transitions, lifecycle hooks. Consolidate events (1 not 15), debounce high-frequency updates, DRY shared payload fields, guard emit() for production. Transparent server/client event bridging.