
Skill
devtools-bidirectional
implement bidirectional devtools communication
Description
Two-way event patterns between devtools panel and application. App-to-devtools observation, devtools-to-app commands, time-travel debugging with snapshots and revert. structuredClone for snapshot safety, distinct event suffixes for observation vs commands, serializable payloads only.
SKILL.md
devtools-bidirectional
Prerequisite: Read and understand the
devtools-event-clientskill first. This skill builds onEventClient, its event map types,emit()/on()API, pluginId namespacing, connection lifecycle, and singleton pattern. Everything here assumes you already have a workingEventClientinstance.
Two-way communication between your application and a TanStack Devtools panel using EventClient. The same client instance handles both directions: the app emits observation events that the panel listens to, and the panel emits command events that the app listens to.
Core Concept
EventClient is not unidirectional. Both emit() and on() work from either side -- application code or panel code -- on the same shared event bus. The direction is a convention you establish through your event map design, not a limitation of the API.
App code calls: client.emit('state-update', ...) // observation
Panel code calls: client.on('state-update', ...) // observation
Panel code calls: client.emit('set-state', ...) // command
App code calls: client.on('set-state', ...) // command
Core Patterns
1. App-to-Devtools Observation
The app emits state changes. The panel listens and renders.
Event map and client (shared module):
import { EventClient } from '@tanstack/devtools-event-client'
type CounterEvents = {
// Observation: app -> panel
'state-update': { count: number; updatedAt: number }
}
class CounterDevtoolsClient extends EventClient<CounterEvents> {
constructor() {
super({
pluginId: 'counter-inspector',
enabled: process.env.NODE_ENV !== 'production',
})
}
}
export const counterClient = new CounterDevtoolsClient()
App side -- emit on state changes:
import { counterClient } from './counter-devtools-client'
function increment() {
count += 1
counterClient.emit('state-update', {
count,
updatedAt: Date.now(),
})
}
Panel side -- listen and display:
import { counterClient } from './counter-devtools-client'
const cleanup = counterClient.on('state-update', (event) => {
// event.payload.count
// event.payload.updatedAt
renderPanel(event.payload)
})
2. Devtools-to-App Commands
The panel sends commands. The app listens and mutates state.
Extend the event map with command events:
type CounterEvents = {
// Observation: app -> panel
'state-update': { count: number; updatedAt: number }
// Commands: panel -> app
reset: void
'set-count': { count: number }
}
Panel side -- emit commands on user interaction:
import { counterClient } from './counter-devtools-client'
function handleResetClick() {
counterClient.emit('reset', undefined)
}
function handleSetCount(newCount: number) {
counterClient.emit('set-count', { count: newCount })
}
App side -- listen for commands and react:
import { counterClient } from './counter-devtools-client'
counterClient.on('reset', () => {
count = 0
// Re-emit observation so panel updates
counterClient.emit('state-update', {
count,
updatedAt: Date.now(),
})
})
counterClient.on('set-count', (event) => {
count = event.payload.count
counterClient.emit('state-update', {
count,
updatedAt: Date.now(),
})
})
The command handler re-emits an observation event after mutating state. This closes the loop so the panel sees the result of its own command.
3. Time-Travel Debugging
Combine observation (snapshots) with commands (revert) to build a time-travel slider.
Event map:
type TimeTravelEvents = {
// Observation: app -> panel
snapshot: { state: unknown; timestamp: number; label: string }
// Command: panel -> app
revert: { state: unknown }
}
class TimeTravelClient extends EventClient<TimeTravelEvents> {
constructor() {
super({
pluginId: 'time-travel',
enabled: process.env.NODE_ENV !== 'production',
})
}
}
export const timeTravelClient = new TimeTravelClient()
App side -- emit snapshots with structuredClone:
import { timeTravelClient } from './time-travel-client'
function applyAction(action: { type: string; payload: unknown }) {
state = reducer(state, action)
timeTravelClient.emit('snapshot', {
state: structuredClone(state),
timestamp: Date.now(),
label: action.type,
})
}
// Listen for revert commands from devtools
timeTravelClient.on('revert', (event) => {
state = event.payload.state
rerender()
})
structuredClone(state) is required here. Without it, the snapshot payload holds a reference to the live state object. When the app mutates state later, all previously stored snapshots in the panel are corrupted because they point to the same object.
Panel side -- collect snapshots and revert:
import { timeTravelClient } from './time-travel-client'
function TimeTravelPanel() {
const [snapshots, setSnapshots] = useState<
Array<{ state: unknown; timestamp: number; label: string }>
>([])
const [index, setIndex] = useState(0)
useEffect(() => {
return timeTravelClient.on('snapshot', (event) => {
setSnapshots((prev) => [...prev, event.payload])
setIndex((prev) => prev + 1)
})
}, [])
const handleSliderChange = (newIndex: number) => {
setIndex(newIndex)
timeTravelClient.emit('revert', {
state: snapshots[newIndex].state,
})
}
return (
<div>
<input
type="range"
min={0}
max={snapshots.length - 1}
value={index}
onChange={(e) => handleSliderChange(Number(e.target.value))}
/>
<p>
{snapshots[index]?.label} (
{new Date(snapshots[index]?.timestamp).toLocaleTimeString()})
</p>
<pre>{JSON.stringify(snapshots[index]?.state, null, 2)}</pre>
</div>
)
}
After the app handles revert, it should re-emit a snapshot so the panel timeline stays current. The revert handler in the app side example above does not re-emit -- add it if your UI needs the timeline to update after a revert:
timeTravelClient.on('revert', (event) => {
state = event.payload.state
rerender()
// Optional: re-emit so the timeline reflects the revert
timeTravelClient.emit('snapshot', {
state: structuredClone(state),
timestamp: Date.now(),
label: 'revert',
})
})
4. Bidirectional Event Map Design
When a single plugin needs both observation and command events, define them all in one event map. Use naming conventions to distinguish direction:
type StoreInspectorEvents = {
// Observation: app -> panel (describe what happened)
'state-update': { storeName: string; state: unknown; timestamp: number }
'action-dispatched': { storeName: string; action: string; payload: unknown }
'error-caught': { storeName: string; error: string; stack?: string }
// Commands: panel -> app (describe what to do)
'set-state': { storeName: string; state: unknown }
'dispatch-action': { storeName: string; action: string; payload: unknown }
reset: void
revert: { state: unknown }
}
Naming convention:
- Observation events describe what happened:
state-update,action-dispatched,error-caught,snapshot - Command events describe what to do:
set-state,dispatch-action,reset,revert
This distinction is purely a convention in your event map keys. The EventClient API is the same for both. But maintaining it makes your event map self-documenting and prevents confusion about which side emits vs listens.
Full bidirectional wiring with one client:
import { EventClient } from '@tanstack/devtools-event-client'
type StoreInspectorEvents = {
'state-update': { storeName: string; state: unknown; timestamp: number }
'set-state': { storeName: string; state: unknown }
reset: void
}
class StoreInspectorClient extends EventClient<StoreInspectorEvents> {
constructor() {
super({
pluginId: 'store-inspector',
enabled: process.env.NODE_ENV !== 'production',
})
}
}
export const storeInspector = new StoreInspectorClient()
App side:
import { storeInspector } from './store-inspector-client'
// Observation: emit state changes
function updateStore(storeName: string, newState: unknown) {
stores[storeName] = newState
storeInspector.emit('state-update', {
storeName,
state: structuredClone(newState),
timestamp: Date.now(),
})
}
// Command handlers: listen for panel commands
storeInspector.on('set-state', (event) => {
const { storeName, state } = event.payload
stores[storeName] = state
storeInspector.emit('state-update', {
storeName,
state: structuredClone(state),
timestamp: Date.now(),
})
})
storeInspector.on('reset', () => {
for (const storeName of Object.keys(stores)) {
stores[storeName] = initialStates[storeName]
storeInspector.emit('state-update', {
storeName,
state: structuredClone(initialStates[storeName]),
timestamp: Date.now(),
})
}
})
Panel side:
import { storeInspector } from './store-inspector-client'
// Observation: listen for state changes
storeInspector.on('state-update', (event) => {
renderStore(event.payload.storeName, event.payload.state)
})
// Commands: emit on user action
function handleEditState(storeName: string, newState: unknown) {
storeInspector.emit('set-state', { storeName, state: newState })
}
function handleReset() {
storeInspector.emit('reset', undefined)
}
Debouncing Frequent Observations
High-frequency state changes (e.g., mouse tracking, animation frames) can flood the event bus. Debounce on the emit side:
import { storeInspector } from './store-inspector-client'
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function emitStateUpdate(storeName: string, state: unknown) {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
storeInspector.emit('state-update', {
storeName,
state: structuredClone(state),
timestamp: Date.now(),
})
}, 16) // ~60fps cap
}
Do not debounce command events. Commands are user-initiated and infrequent.
Common Mistakes
1. Not using structuredClone for snapshots (HIGH)
Without structuredClone, snapshot payloads hold references to the live state object. When the app mutates state later, every stored snapshot in the panel is silently corrupted.
Wrong:
timeTravelClient.emit('snapshot', {
state,
timestamp: Date.now(),
label: action.type,
})
The panel stores event.payload.state, which is a reference to the app's state variable. On the next mutation, the panel's stored snapshot now reflects the new state, not the historical state.
Correct:
timeTravelClient.emit('snapshot', {
state: structuredClone(state),
timestamp: Date.now(),
label: action.type,
})
structuredClone creates a deep copy. The snapshot is frozen in time regardless of future mutations. This applies to any observation event where the panel accumulates historical data -- not just time-travel.
2. Non-serializable payloads in cross-tab scenarios (HIGH)
When using the server event bus (WebSocket/SSE/BroadcastChannel), payloads are serialized for transport. Functions, DOM nodes, class instances with methods, Map, Set, WeakRef, and circular references all fail silently or lose data.
This is especially dangerous in bidirectional patterns because command payloads flow panel-to-app and may cross transport boundaries.
Wrong:
storeInspector.emit('set-state', {
storeName: 'main',
state: {
items: new Map([['a', 1]]), // Map -- lost on serialization
onClick: () => alert('hi'), // Function -- lost on serialization
ref: document.getElementById('x'), // DOM node -- lost on serialization
},
})
Correct:
storeInspector.emit('set-state', {
storeName: 'main',
state: {
items: Object.fromEntries(new Map([['a', 1]])),
timestamp: Date.now(),
},
})
Rule of thumb: if JSON.parse(JSON.stringify(payload)) does not round-trip cleanly, the payload is not safe for the event bus.
3. Not distinguishing observation from command events (MEDIUM)
Mixing naming conventions makes the event map confusing and error-prone. Developers end up emitting observation events from the panel or command events from the app, breaking the communication contract.
Wrong:
type MyEvents = {
state: unknown // Is this observation or command?
update: unknown // Who emits this?
count: number // Unclear direction
}
Correct:
type MyEvents = {
'state-update': unknown // Observation: describes what happened
'set-state': unknown // Command: describes what to do
'count-changed': number // Observation: past tense / descriptive
reset: void // Command: imperative
}
Use observation suffixes that describe what happened (-update, -changed, -dispatched, -caught). Use command suffixes that describe what to do (set-, dispatch-, reset, revert). The naming convention is not enforced by the API, but consistent naming prevents wiring mistakes.
See Also
devtools-event-client-- base event system: event maps,emit()/on(), connection lifecycle, singleton patterndevtools-instrumentation-- strategic placement ofemit()calls in library code benefits from bidirectional awareness (knowing that commands will flow back)
More skills from the devtools repository
View all 9 skillsdevtools-app-setup
configure TanStack Devtools for applications
Jul 16DebuggingFrontendTanStackdevtools-event-client
implement typed event clients for libraries
Jul 16API DevelopmentEngineeringTanStackdevtools-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