Back to Hub
DEBUGGING •
By Yash Panchal · Last updated:

Nitro.watch(): A Live State Panel for My Browser IDE

You know the ritual. You scatter console.log through your code, reload, and then do archaeology: scroll up through the noise, squint at timestamps, try to remember which log came from which branch, and hope the interesting one didn't get buried under a loop that fired 400 times. In vanilla JS there's no Redux DevTools to lean on — just you and the dig site.

I build NitroIDE, a browser-based IDE where your code runs only in your browser. A few months ago I got tired enough of the archaeology to build the poor man's state panel directly into it: one call — Nitro.watch('cart', cart) — and the IDE renders your value as a live, syntax-highlighted JSON tree in the State tab. Every time you call watch with the same name, the tree updates. No config, no extension, no devtools tab-switching.

It turned out to be a small, opinionated piece of engineering. Here's the whole thing, including the decisions I made and the limits I kept.

The API is one function

Inside your preview code:

let playerState = { health: 100, level: 5, items: ['Sword'] };

// Bind it to the IDE's State tab
Nitro.watch('Player Data', playerState);

// Automatically updates the panel every second
setInterval(() => {
  playerState.health -= 5;
  Nitro.watch('Player Data', playerState);
}, 1000);

That's the entire public surface. window.Nitro is injected into the preview iframe by the IDE itself, and watch(name, data) takes a human-readable label plus any value. The docs example above is a game loop — call watch with the same name on a timer and the State tab becomes a live HUD. No subscription API, no disposal ceremony. I wanted the debugging tool to be as zero-setup as the IDE.

Underneath, it's a postMessage bridge

NitroIDE's preview runs in a sandboxed iframe on an opaque origin (I removed allow-same-origin during the v25 security audit), so the parent page and the preview can only talk through postMessage. The state watcher rides that same channel, and the whole pipeline has exactly three moving parts:

  1. The injected hook. In the preview, watch(name, data) does one thing: window.parent.postMessage({ type: 'state-watch', name: name, data: JSON.stringify(data) }, '*'). One direction, no polling, no requests.
  2. The cache. The parent keeps a stateCache object keyed by watch name. On each message it runs stateCache[name] = JSON.parse(data) and re-renders. If parsing fails (say, JSON.stringify(undefined) — which returns undefined, not a string), the catch stores the raw string instead of crashing the panel.
  3. The renderer. Each watched name gets a header row plus a <pre class="json-block"> rendered by a hand-rolled syntaxHighlightJSON — a single regex over the 2-space pretty-printed JSON that tokenizes keys, strings, numbers, booleans, and nulls into CSS classes. No dependency, about fifteen lines.

That's it. Value in, JSON tree out. The panel re-renders on every watch call.

The decisions I made — and why

Latest wins, deliberately. Watching the same name overwrites the previous snapshot. It's a state panel, not a log. No history, no diffs, no time travel. I chose to be honest about what the tool is rather than ship a fake version of what it isn't. If you want the archaeology back, console.log is still right there.

One-way by design. The preview never asks the parent for anything, and the parent never reaches into the iframe. The channel is fire-and-forget, which means a watch call can never block your app's frame — the worst case is a dropped or delayed render.

The parent never trusts the payload. The watch data crosses the exact same iframe→parent channel we hardened in v25, so it gets the same treatment. The watch name is HTML-escaped before it touches the DOM. The data is re-parsed as JSON and run through the highlighter, which escapes the text first and tokenizes second — markup can never sneak through the highlighting step, because by the time the regex runs, there is no markup left. A malicious preview trying to inject HTML through a watched value gets a harmless escaped string in a <pre>.

Zero dependencies. The syntax highlighter is one regex. The bridge is postMessage plus a cache object. The whole feature is maybe forty lines. Debugging tools have to survive every project, so they can't depend on any project's stack.

The honest ceiling

Every tool should state its ceiling, so here it is:

None of these are bugs. They're the shape of a tool that chose to be small.

The lesson I keep relearning

Debugging tools in a zero-setup IDE have to be zero-setup too. The moment a debugger needs a config file, an extension, or a devtools tab-switch, I won't reach for it when I'm actually in flow — I'll reach for console.log and start digging again. The state watcher exists because the friction had to be lower than the bad habit. One call, one tree, live. That was the whole spec.

Try it

NitroIDE is free, no signup, and your code runs only in your browser: nitroide.com

⭐ If you enjoyed this, star NitroIDE on GitHub: github.com/nitroideofficial/nitroide

Frequently asked questions

What does Nitro.watch() do?

One call — Nitro.watch('name', value) — renders the value as a live, syntax-highlighted JSON tree in NitroIDE's State tab, updating on every call with the same name.

Does Nitro.watch() work with circular objects?

No — JSON.stringify runs in your preview code, so a circular object throws at the watch() call itself before anything is sent.

Can a watched value inject HTML into the IDE?

No — the watch name is HTML-escaped, and the data is re-parsed as JSON and escaped before highlighting, so markup renders as harmless text.

Does watching a value slow down my app?

Each call stringifies, sends, parses, and re-renders the panel, so watch on state transitions rather than every frame for large objects.