Developer tooling
This is the payoff of a pure-JS stack: a react-x11 app is an ordinary Node process, so the whole V8 and React tooling story applies unmodified. Nothing on this page needs an adapter.
The four tools answer four different questions, and it is worth knowing which is which before you reach for one:
| Question | Tool |
|---|---|
| Which component rendered, and with what props? | React DevTools |
| Was that render wasted? | why-did-you-render |
| Where did the milliseconds go? | node --inspect, 0x |
| Why is memory growing? | node --inspect heap snapshots |
react-scan is not on this page: its documented entry point does not resolve
under bare Node, and its programmatic API silently never fires. See the
negative-results register. React's own
<Profiler> works unchanged under react-x11 and covers the same ground for
commit timings.
React DevTools
Out of the box — already wired behind an env var. react-devtools-core@7.0.1.
react-devtools-core is the backend half of React DevTools, made for exactly
this situation: a React tree running outside a browser page. It connects over
WebSocket to the standalone DevTools UI and streams the fiber tree, props,
hooks and profiler data. The integration lives in
src/DevToolsIntegration.js and costs nothing when off.
npm i -D react-devtools-core ws
# terminal 1 — standalone UI, listens on :8097
npx react-devtools
# terminal 2 — any app
REACT_X11_DEVTOOLS=1 npm run examples:dashboard
src/Reconciler.js checks REACT_X11_DEVTOOLS when you first call
render()/createRoot(), and installs the DevTools global hook before the
first commit — that ordering is what makes mounted roots visible, and it
holds because a commit can only follow a root.
REACT_X11_DEVTOOLS_HOST and REACT_X11_DEVTOOLS_PORT point the backend at
a UI elsewhere, which is useful when the app runs on a headless box next to
the X server.
Working today: the live component tree, props/hooks/state inspection, and highlight-on-hover — hovering an element in the tree tints its rect in the X11 window. See devtools for the full walkthrough.
- Start the standalone UI first, or wait for the backend's retry. The app
side retries; the UI side does not attach to an already-running app unless
the hook was installed at startup, and
REACT_X11_DEVTOOLScannot be turned on after the fact. - The Profiler's commit-oriented views (flamegraph, ranked) work with the dev
reconciler. The Timeline view cannot — it needs
injectProfilingHooks, which react-reconciler 0.33 does not expose at all. injectIntoDevToolstakes zero arguments in react-reconciler 0.33, so the config object passed to it is silently discarded andfindFiberByHostInstanceis never registered — DevTools cannot map a host node back to its fiber. The tree, props and highlight features do not need it. Do not add options to that call expecting them to do anything; the supported channel isextraDevToolsConfigon the host config.
react-refresh
Out of the box — the setup is already in the repo. react-refresh@0.18.0 with hot-module-replacement@4.0.0.
Fast Refresh without a bundler: edit a component, and the mounted X11 window
updates in place with hook state intact. examples/hmr-register.mjs and
examples/hmr-refresh.js are the two halves.
node --enable-source-maps --import ./examples/hmr-register.mjs examples/tasks-hot.jsx
Node 22.15 or newer is required, for synchronous module.registerHooks.
hmr-register.mjs stacks two loader layers: babel (classic JSX transform
plus react-refresh/babel) instruments every .jsx component, and above it
hot-module-replacement rewrites static imports into live bindings and wires
import.meta.hot. hmr-refresh.js is the runtime half, and its ordering is
load-bearing: RefreshRuntime.injectIntoGlobalHook(globalThis) must patch
the DevTools hook before the renderer registers, which is why the react-x11
import below it is dynamic.
The hot boundary excludes node_modules and src/, so React, the renderer
and react-refresh/runtime stay singletons — the X connection and the
mounted window survive reloads, and only app modules re-execute.
// Minimal hot entry for an app (mirrors examples/tasks-hot.jsx):
import { performReactRefresh } from './examples/hmr-refresh.js'; // FIRST import
import { createRoot } from 'react-x11'; // safe now: the global hook is patched
import { App } from './app-ui.jsx'; // hot: lives in the reloadable graph
(await createRoot()).render(<App />);
import.meta.hot?.accept('./app-ui.jsx', () => {
performReactRefresh();
});
- Named imports in hot modules re-initialize in a microtask after a
reload, so module top-level code must not call them synchronously. Use the
default import at top level (
React.createContext(...), notcreateContext(...)). Inside components anything goes. - The import-rewrite layer emits replacements with no trailing semicolon, so hot modules want one statement per line.
- Stack traces through hot modules are off by the prelude's four lines. Dev-only.
- Fast Refresh needs the dev reconciler (
NODE_ENVunset ordevelopment). There is no production hot path, by design.
node --inspect
Out of the box. Node built-in.
Node's V8 inspector protocol: node --inspect exposes a debug port, and
Chrome's chrome://inspect (or VS Code's auto-attach) connects a full
DevTools instance — sourcemap-aware CPU profiler, heap snapshots, allocation
timelines, live debugging.
Everything that costs time in a react-x11 app is visible as ordinary JS frames, which is why this is the recommended profiling path:
- CPU profiles of commits. Reconciler frames (
performUnitOfWork,completeWork), yoga layout (calculateLayout), style resolution and ntk paint/flush show up in one call tree. That answers "is it React, layout, or painting" in a single recording. - Heap snapshots of retained nodes. Every drawn element is a retained
node object reachable from the ntk window's
_reactX11Node. Snapshot, filter the constructor list, and leaked subtrees — a<popup>that never unmounted, listeners held after close — are directly countable. Two snapshots plus "Objects allocated between…" isolates a leak per interaction.
# interactive: profile a running app from Chrome
node --inspect --import tsx examples/dashboard.jsx
# chrome://inspect -> Open dedicated DevTools for Node -> Performance / Memory
# capture-on-exit CPU profile, no UI needed
node --cpu-prof --cpu-prof-dir=./profiles --import tsx examples/dashboard.jsx
# heap snapshot of a live app without restarting it
kill -USR1 <pid> # inspector comes up on :9229, then attach and snapshot
- Profile with
NODE_ENV=productionwhen chasing React costs. The dev reconciler's stack-frame bookkeeping (runWithFiberInDEV) inflates commit times and clutters the tree. For layout and paint costs it matters less. - The inspector port is an arbitrary-code-execution door. Bind it to
127.0.0.1(the default) and never expose it on a network; for remote boxes, tunnel it withssh -L 9229:localhost:9229. - This and the React DevTools Profiler are complementary: DevTools names the components per commit, the V8 profiler names the functions.
0x
Out of the box. 0x@6.0.0.
A single-command flamegraph generator: 0x -- node app.js runs the process
under the V8 sampling profiler and, on exit, writes a self-contained
interactive flamegraph.html. No integration at all, and the interesting
frames are directly searchable — performUnitOfWork/completeWork, yoga,
and your own components.
# profile an example, write the output into a throwaway dir
npx 0x --output-dir /tmp/rx11-prof -- node --import tsx examples/dashboard.jsx
# interact with the window until the slow thing happens, then Ctrl-C
# -> file:///tmp/rx11-prof/flamegraph.html
# tips inside the flamegraph UI:
# search "yoga" -> layout cost
# search "performUnitOfWork" -> react reconcile cost
# "Merge" view -> collapses recursion for a cleaner ranking
Why 0x and not clinic.js: Clinic's flame command is a wrapper around 0x, so for flamegraphs it adds moving parts without adding signal. Its other tools target server event-loop and async-I/O patterns that do not map onto a GUI render loop, and the package has been dormant since early 2024 while 0x keeps shipping.
- Sampling profiler: sub-millisecond handlers need a longer capture to show up, so drive the interaction in a loop.
- Profile with
NODE_ENV=productionwhen measuring React itself. - Running through the tsx loader is fine, though frames from
.jsxfiles show transformed function names. Component names usually survive. - 0x answers "where does CPU time go", and says nothing about which commit
or why it re-rendered. Pair it with the DevTools Profiler and
why-did-you-render. For heap questions use
node --inspect; 0x is CPU-only.
why-did-you-render
Out of the box. @welldone-software/why-did-you-render@10.0.1.
Patches the React namespace so that components you flag report avoidable
re-renders — props or hook state that changed by reference but are equal by
value. It hooks React itself, not react-dom, which is exactly why it works
under a custom renderer.
import React from 'react';
import whyDidYouRender from '@welldone-software/why-did-you-render';
import { createRoot } from 'react-x11';
whyDidYouRender(React); // before anything renders
const Label = ({ user }) => <text>{`hi ${user.name}`}</text>;
Label.whyDidYouRender = true;
// Every render passes a fresh-but-equal object — the classic wasted render:
const App = () => (
<window width={300} height={200} title="wdyr">
<box style={{ flexGrow: 1 }}>
<Label user={{ name: 'ada' }} />
</box>
</window>
);
(await createRoot()).render(<App />);
// terminal: "Label … props.user: different objects that are equal by value."
- Props tracking reports a re-render caused by a new-but-deep-equal object
prop with
diffType: 'deepEquals'and the offending path. Hook tracking (trackHooks, on by default) reportssetStatewith an equal-by-value object the same way. - The default notifier prints readable
console.groupoutput in a terminal — no browser console needed. A customnotifierreceives the structured{displayName, reason: {propsDifferences, hookDifferences}}object, which is also how you assert on wasted renders in tests. - Works with the automatic JSX runtime as compiled by tsx and esbuild; the babel alias the docs describe for webpack setups is not needed.
- Install it before components are defined and before the first render —
the patch wraps
React.createElementand the hook entry points on the namespace. - Dev-only by design. With
NODE_ENV=productionReact's prod build drops the internals it reads; under plainnode/tsxyou get the dev build, which is the right mode for it anyway. - Updates scheduled outside an event handler flush on a delayed task in React 19, so in a scripted repro, wait a macrotask before asserting the notification fired.
This complements React DevTools: DevTools tells you that a component rendered; why-did-you-render tells you the render was wasted and which value to memoize.