Skip to content
Homeostate

Devtools

@homeostate/tool-devtools

Floating React devtools to inspect and edit the state a homeostate sync engine keeps in sync

@homeostate/tool-devtools@0.1.0npmSource

React devtools for @homeostate/core. A floating button opens a panel docked to the edge of the page, where you can inspect and edit a store's state, compare it with the synced backend document, follow a log of every change, and connect or disconnect the sync engine.

Install

npm install -D @homeostate/tool-devtools

react and react-dom 18 or 19 are peer dependencies. The package brings its own styles, so the app needs no Tailwind or shadcn setup.

Usage

Render HomeostateDevtools anywhere in the app and hand it the pieces of each sync setup: the store adapter, and optionally the backend and engine.

import * as Y from "yjs";
import { createSyncEngine } from "@homeostate/core";
import { createYjsBackend } from "@homeostate/crdt-yjs";
import { createZustandAdapter } from "@homeostate/store-zustand";
import { HomeostateDevtools } from "@homeostate/tool-devtools";

const adapter = createZustandAdapter(useTodoStore);
const backend = createYjsBackend(new Y.Doc(), "shared");
const engine = createSyncEngine(backend, adapter);
engine.connect();

export function App() {
  return (
    <>
      <TodoList />
      <HomeostateDevtools
        sources={[{ name: "Todos", adapter, backend, engine }]}
      />
    </>
  );
}

Pass several sources to inspect several stores; the panel then shows a switcher. With the Zustand homeostate middleware, create an adapter for the devtools with createZustandAdapter(store) and pass store.homeostate as the engine.

PropDefaultDescription
sources{ name, adapter, backend?, engine?, filter? } for each store
buttonPosition"bottom-right"Viewport corner of the button: bottom-left, top-right, top-left
panelPosition"right"Edge the panel docks to: left, bottom, top
initialIsOpenfalseWhether the panel starts open, until it is opened or closed once
openControls whether the panel is open, to open it from your own UI
onOpenChangeCalled with the new state when the panel is opened or closed
theme"system"light, dark, or system to follow prefers-color-scheme
logLimit200Log entries kept per source

To open the panel from your own UI, such as an "Inspect state" button, control it with open and onOpenChange; the devtools then leave remembering it to you.

Pass the engine's filter in the source too, if it has one, so the keys it keeps out of sync are marked local.

The panel

The panel is not modal: the page stays usable while it is open, so you can use the app and watch its state change. Drag its inner edge, or focus it and use the arrow keys, to resize it. Whether it is open, its size and its tab are remembered in localStorage.

  • State shows the store as a tree or as JSON. Click a value to edit it, click a boolean to toggle it, edit an object or array as JSON, or delete a key or item. The JSON view edits the whole state. Each top-level key is marked with how it relates to the backend.
  • Sync lists every key as synced, diverged (the backend holds another value, as it may while disconnected), pending (not in the backend yet), local (kept out by the filter) or backend only, and shows the backend document read-only.
  • Log records each change to the store with its diff, marked local, remote (a peer's change applied through the backend) or devtools. Any entry can be restored. Recording can be paused and the log cleared.
  • The header shows whether the engine is connected, with a switch to disconnect and reconnect it.

Edits go through the store

Every edit, including a restore, calls the source's adapter.setState, so the engine writes it to the backend like any other local change and every peer receives it. Keys that are not JSON, such as a Zustand store's actions, are left as they are, and unchanged subtrees keep their identity, so the store sees a change only where you made one. The backend is never written directly.

Warning

Restoring a log entry is not local time travel: it replaces the shared state for every peer in the room.

While the engine is disconnected, edits stay in the local store. Reconnecting adopts the backend's values, as connect() always does, so edits made in the meantime to keys the backend holds are dropped.

Styles

The devtools render into a shadow root on document.body, with a stylesheet compiled into the package. The page's CSS does not reach the panel, and the panel's does not reach the page. The only thing added to the document is Tailwind's @property registrations, which browsers ignore inside a shadow root.

Production builds

The component renders in every build. To leave it out of production bundles, load it only in development:

import { lazy } from "react";

const HomeostateDevtools = import.meta.env.DEV
  ? lazy(() =>
      import("@homeostate/tool-devtools").then((module) => ({
        default: module.HomeostateDevtools,
      })),
    )
  : () => null;

Render it inside a Suspense boundary.