Charlie KrugThe Build Log

← All posts

The assignment never changed. Your state field became a tripwire.

Statelight swaps a plain object's state property for a getter/setter with Object.defineProperty, so machine.state = x still works but now lights up a live graph. One script tag, no library, under 3kb.

You wrote machine.state = 'running'. Somewhere on the page a floating panel just lit up the edge from idle to running, drew it into a graph, and pushed a line onto a transition log. You didn't call anything. You didn't publish an event or await a hook. That's because state stopped being a normal field the moment Statelight attached to it, and the assignment you already had in your code is now the trigger.

Most small apps never reach for a real state-machine library. You put a state string on a plain object and grow a pile of if/switch logic around it, because pulling in XState for a three-state toggle is overkill. That's the right call, right up until you're three transitions deep in a bug asking "what state am I even in, and how did I get here." Every existing FSM visualizer answers that only if you already bought into its library, its event format, and usually its build step. Statelight assumes nothing except that you have an object with a state property somewhere.

The property isn't a property anymore

The whole trick is Object.defineProperty. A field like machine.state is normally a data property: a slot that holds a value. When you attach, Statelight reads the current value, stashes it in a closure, and redefines state as an accessor property instead: a getter that hands back the stashed value, and a setter that records the transition, updates the panel, and then stores the new value. A property can be one or the other, never both, which is exactly why the stash has to live in the closure. From your code's point of view nothing changed. machine.state still reads and writes like a string. It just also tells someone now.

This is deliberately the opposite of how instrumentation usually works. No event bus to publish to, no per-library adapter, no wrapper function you have to remember to route every write through. And because the setter fires synchronously on the assignment itself, detection is exact: no polling timer, no sampling interval, no intermediate state that flashed by between two ticks and got missed. Every transition is caught the instant it happens, in order.

The map is optional, and that's the point

Hand attach nothing else and you still get something useful: the current state and a scrolling trail of recent transitions. Hand it a transitions map (the same nested object you'd write for any FSM) and the panel upgrades itself into the full state graph, drawing every node and edge and lighting up the active edge as the machine walks it. The zero-config path stays genuinely zero-config; the graph is a reward for describing your machine, not a tax you pay up front.

A couple of honest edges. The getter/setter only works on a property, so if your state lives in a closure variable or a React useState cell, there's nothing on an object to redefine. Attach to the plain object your machine already keeps, not the hook. And the "one script tag" pitch has a boring enemy: a second required <link> for CSS would break it. So the panel's styles live as a string in the source and get injected into <head> on first mount, and the whole shipped bundle stays under 3kb gzipped with zero runtime dependencies. It's a debugging aid meant to sit in a project indefinitely, not something you should think twice about adding.

Try it

Open the demo, where the hero is a real running Statelight instance driving an example machine, and click the buttons to watch the graph light up edge by edge. To drop it into your own code, add one <script type="module">, import attach, and call it on the object that already has your state field. Pass your transition map if you have one; skip it if you don't. Either way the panel appears, and you can collapse it, drag it, or attach a second machine on the same page without the panels fighting. The source is on GitHub.

Statelight is live. Free, in your browser, no signup.

This post is part of the build log: every app my automated factory ships gets written up here, honestly. Browse everything at apps.charliekrug.com. Comments are open below.

Comments

Loading comments…