You can build custom devtools plugins for any state management library, API client, or internal tool. A plugin consists of two parts:
An EventClient — sends and receives data between your application code and the devtools panel.
A panel component — displays the data inside the devtools UI.
graph LR
subgraph app["Your Application"]
lib["Library / State"]
end
subgraph ec["EventClient"]
emit["emit()"]
on["on()"]
end
subgraph dt["Devtools Panel"]
panel["Panel Component"]
end
lib -- "State changes" --> emit
emit -- "Events" --> panel
panel -- "Commands" --> on
on -- "Actions" --> libThe EventClient is framework-agnostic. It works the same in React, Vue, Solid, Preact, or vanilla JavaScript. The panel component can be written in any framework supported by an adapter.
This guide walks through building a custom plugin from scratch using a "store inspector" as a running example.
Start by creating a TypeScript type that maps event names to their payload types:
type StoreEvents = {
'state-changed': { storeName: string; state: unknown; timestamp: number }
'action-dispatched': { storeName: string; action: string; payload: unknown }
'reset': void
}Each key in the event map is just the event name (the suffix). Do not include the pluginId in the key — the EventClient prepends the pluginId automatically when emitting and listening. For example, if pluginId is 'store-inspector' and the key is 'state-changed', the fully qualified event dispatched on the bus will be 'store-inspector:state-changed'.
The value of each key is the payload type. Use void for events that carry no data.
Extend the base EventClient class with your event map:
import { EventClient } from '@tanstack/devtools-event-client'
class StoreInspectorClient extends EventClient<StoreEvents> {
constructor() {
super({ pluginId: 'store-inspector' })
}
}
export const storeInspector = new StoreInspectorClient()Install the event client package if you haven't already:
npm i @tanstack/devtools-event-clientThe constructor accepts additional options beyond pluginId:
| Option | Type | Default | Description |
|---|---|---|---|
| pluginId | string | — | Required. Identifies this plugin in the event system. |
| debug | boolean | false | Enable verbose console logging. |
| enabled | boolean | true | Whether the client connects to the event bus. |
| reconnectEveryMs | number | 300 | Interval (ms) between connection retry attempts. |
See the Event System page for the full connection lifecycle details.
Call emit() from your library code whenever something interesting happens. You pass only the suffix part of the event name — the pluginId is prepended automatically.
function dispatch(action, payload) {
// Your library logic
state = reducer(state, action, payload)
// Emit to devtools
storeInspector.emit('state-changed', {
storeName: 'main',
state,
timestamp: Date.now(),
})
storeInspector.emit('action-dispatched', {
storeName: 'main',
action,
payload,
})
}Common patterns for where to call emit():
In state mutations — emit the new state after every update (as shown above).
In observers or subscriptions — if your library uses a subscriber/observer pattern, emit from the notification callback.
In middleware — if your library supports middleware or interceptors, emit from a middleware layer so it works automatically for all operations.
If the devtools are not yet mounted when you emit, events are queued and flushed once the connection succeeds. If the connection never succeeds (e.g., devtools are not present), events are silently dropped after 5 retries. This means you can leave emit() calls in your library code without worrying about whether the devtools are active.
Create a component that listens for events via on() and renders the data. Here is a React example:
import { useState, useEffect } from 'react'
import { storeInspector } from './store-inspector-client'
function StoreInspectorPanel() {
const [state, setState] = useState<Record<string, unknown>>({})
const [actions, setActions] = useState<Array<{ action: string; payload: unknown }>>([])
useEffect(() => {
const cleanupState = storeInspector.on('state-changed', (e) => {
setState(prev => ({ ...prev, [e.payload.storeName]: e.payload.state }))
})
const cleanupActions = storeInspector.on('action-dispatched', (e) => {
setActions(prev => [...prev, { action: e.payload.action, payload: e.payload.payload }])
})
return () => { cleanupState(); cleanupActions() }
}, [])
return (
<div>
<h3>Current State</h3>
<pre>{JSON.stringify(state, null, 2)}</pre>
<h3>Action Log</h3>
<ul>
{actions.map((a, i) => <li key={i}>{a.action}: {JSON.stringify(a.payload)}</li>)}
</ul>
</div>
)
}Like emit(), the on() method takes only the suffix. The callback receives the full event object with a typed payload property. Each on() call returns a cleanup function that removes the listener.
Note
When using plugin factories from @tanstack/devtools-utils (covered below), your panel component receives a theme prop ('light' | 'dark') so you can adapt your UI to the current devtools theme.
Pass your plugin to the devtools component's plugins array:
import { TanStackDevtools } from '@tanstack/react-devtools'
import { StoreInspectorPanel } from './StoreInspectorPanel'
function App() {
return (
<>
{/* Your app */}
<TanStackDevtools
plugins={[
{
name: 'Store Inspector',
render: <StoreInspectorPanel />,
},
]}
/>
</>
)
}The name is displayed as the tab title in the devtools sidebar. The render field accepts a JSX element (React, Preact) or a component reference (Vue, Solid), depending on your adapter.
You can also pass optional fields:
id — A stable identifier for the plugin. If omitted, one is generated from the name.
defaultOpen — Set to true to open the plugin's panel automatically on first load.
See the Plugin Lifecycle page for the full plugin interface and mount sequence.
Plugins are not limited to one-way data display. You can also send commands from the devtools panel back to your application — for example, "reset state", "replay action", or "toggle feature flag". The same EventClient instance handles both directions: your app emits events that the panel listens to, and the panel emits events that your app listens to.
For a detailed walkthrough with examples, see the Bidirectional Communication guide.
The @tanstack/devtools-utils package provides factory functions that simplify plugin creation for each framework:
createReactPlugin() — for React plugins
createSolidPlugin() — for Solid plugins
createVuePlugin() — for Vue plugins
createPreactPlugin() — for Preact plugins
These factories handle the wiring between your component and the devtools container, pass the theme prop automatically, and return a [Plugin, NoOpPlugin] tuple so you can tree-shake the devtools out of production builds.
For usage details, see the Using devtools-utils guide.
Once your plugin is working, you can share it with the community by publishing it to npm and submitting it to the TanStack Devtools Marketplace. The marketplace is a registry of third-party plugins that users can discover and install directly from the devtools UI.
For submission instructions and the registry format, see Third-party Plugins.