Bidirectional Communication

Most devtools plugins observe state in one direction: app to devtools. But EventClient supports two-way communication. Your devtools panel can also send commands back to the app. This enables features like state editing, action replay, and time-travel debugging.

mermaid

Pattern: App to Devtools (Observation)

The standard one-way pattern. Your app emits events, the devtools panel listens.

ts
// In your app/library
eventClient.emit('state-update', { count: 42 })

// In your devtools panel
eventClient.on('state-update', (e) => {
  setState(e.payload)
})
// In your app/library
eventClient.emit('state-update', { count: 42 })

// In your devtools panel
eventClient.on('state-update', (e) => {
  setState(e.payload)
})

Pattern: Devtools to App (Commands)

The panel emits command events, the app listens and reacts.

ts
// In your devtools panel (e.g., a "Reset" button click handler)
eventClient.emit('reset', undefined)

// In your app/library
eventClient.on('reset', () => {
  store.reset()
})
// In your devtools panel (e.g., a "Reset" button click handler)
eventClient.emit('reset', undefined)

// In your app/library
eventClient.on('reset', () => {
  store.reset()
})

You need to define both directions in your event map:

ts
type MyEvents = {
  // App → Devtools
  'state-update': { count: number }
  // Devtools → App
  'reset': void
  'set-state': { count: number }
}
type MyEvents = {
  // App → Devtools
  'state-update': { count: number }
  // Devtools → App
  'reset': void
  'set-state': { count: number }
}

Pattern: Time Travel

The most powerful bidirectional pattern. Combine observation with command-based state restoration.

mermaid

Event Map

ts
type TimeTravelEvents = {
  'snapshot': { state: unknown; timestamp: number; label: string }
  'revert': { state: unknown }
}
type TimeTravelEvents = {
  'snapshot': { state: unknown; timestamp: number; label: string }
  'revert': { state: unknown }
}

App Side

Emit snapshots on every state change:

ts
function applyAction(action) {
  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', (e) => {
  state = e.payload.state
  rerender()
})
function applyAction(action) {
  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', (e) => {
  state = e.payload.state
  rerender()
})

Panel Side

Collect snapshots and provide a slider:

tsx
function TimeTravelPanel() {
  const [snapshots, setSnapshots] = useState([])
  const [index, setIndex] = useState(0)

  useEffect(() => {
    return timeTravelClient.on('snapshot', (e) => {
      setSnapshots((prev) => [...prev, e.payload])
      setIndex((prev) => prev + 1)
    })
  }, [])

  const handleSliderChange = (newIndex) => {
    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>
        State at: {snapshots[index]?.label} (
        {new Date(snapshots[index]?.timestamp).toLocaleTimeString()})
      </p>
      <pre>{JSON.stringify(snapshots[index]?.state, null, 2)}</pre>
    </div>
  )
}
function TimeTravelPanel() {
  const [snapshots, setSnapshots] = useState([])
  const [index, setIndex] = useState(0)

  useEffect(() => {
    return timeTravelClient.on('snapshot', (e) => {
      setSnapshots((prev) => [...prev, e.payload])
      setIndex((prev) => prev + 1)
    })
  }, [])

  const handleSliderChange = (newIndex) => {
    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>
        State at: {snapshots[index]?.label} (
        {new Date(snapshots[index]?.timestamp).toLocaleTimeString()})
      </p>
      <pre>{JSON.stringify(snapshots[index]?.state, null, 2)}</pre>
    </div>
  )
}

Best Practices

  • Keep payloads serializable. No functions, DOM nodes, or circular references.
  • Use structuredClone() for snapshots to avoid reference mutations.
  • Debounce frequent emissions if needed (e.g., rapid state changes).
  • Use distinct event suffixes for observation vs commands (e.g., state-update for observation, set-state for commands).
Subscribe to Bytes

Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.

Bytes

No spam. Unsubscribe at any time.

Subscribe to Bytes

Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.

Bytes

No spam. Unsubscribe at any time.