Runtime Inspector
A React tree tells you what exists. The runtime graph tells you what it is doing, what it reads, and what caused it to re-render — and the inspector draws that, live, at sixty frames a second.
What is in the graph
The graph is not a component tree with extra fields. It is a typed, directional graph over eleven kinds of node.
The nodes
- Session · User · Screen
- The run, the identity behind it, and the routes it visited.
Screennodes are what a route assertion and the journey view resolve against. - Component
- Every mounted component, with its mount-time
testID, role, text and source location, plus its render count and prop history. Most of what you look at. - Store · Query
- Zustand stores with mutation counts, and TanStack Query entries with status and fetch counts. Both are assertable.
- Gesture · Animation
- Recognised gestures with normalised touch tracks, and running animations.
- Network · Crash · Action
- Requests as spans, captured exceptions with their stacks, and the verbs your app declared with
defineAction.
The edges
This is the part that makes it a graph rather than a table:
- rendered_in · owns
- Structure — the parent spine the tree view lays out.
- subscribes_to · reads_query
- Data dependency. Which components read which store, which query feeds which screen.
- caused_render · triggered · targeted
- Causality. A session-level interaction id stamps every event a touch set in motion, so “this tap caused these fourteen renders” is recorded rather than inferred.
targetedis the weaker spatial relation — the touch landed on this node — kept separate because a tap that hit something and did nothing is a distinct and interesting fact. - mutated · animates · preceded
- State writes, animation ownership, and ordering.
Where the edges come from
None of it is configured. The adapters install themselves on import and each one contributes its own slice: React fiber commits give mounts, renders and prop changes; React Navigation gives routes; Zustand gives mutations and the components subscribed to them; gesture-handler gives touches with normalised tracks; TanStack Query, ScrollView, SQLite, AsyncStorage, MMKV and WebSocket each add their own spans. Ten adapters, none of them wired up by you — and one whose library is absent no-ops, so the graph stays valid either way. The four storage libraries export their entry point in a form nothing can patch at runtime — MMKV v4's re-exported createMMKV, AsyncStorage's read-only default binding, the two SQLite openers — so those are reached by the Babel plugin's import rewrite instead; that is the one reason to add the plugin even if you never want tap-to-source.
Causality is stamped centrally rather than threaded through each adapter: a gesture opens an interaction chain and stays current for a short settle window, and the activation bus stamps that id onto everything event-driven that follows. Periodic samplers — metrics, frame rate — ride a separate raw bus and are never stamped, because a sample that happened to land inside a tap was not caused by it.
The render-flame tree
The default view. A deterministic depth-tiered tidy tree laid out from the resolved parent spine, with each node coloured by render count — green through amber to red — and pulse-ringed the moment it re-renders. Non-tree relationships (subscribes_to, reads_query, and the rest) are drawn as coloured cross-arcs over the top.
What that gets you in practice: a component that is repainting on every keystroke is a red node with an arc back to the store it subscribes to. You are looking at the cause and the effect in one picture, which is not a view any component-tree devtool can produce, because it does not know about the store.
The force-directed view
A toggle. Velocity-Verlet simulation seeded from the tree layout and left to settle, so clusters that are genuinely coupled end up near each other regardless of where they sit in the JSX. This is the exploration mode — good for “what is actually connected to what”, less good for finding a specific component.
Navigating
Pan, zoom, fit
Pan by dragging or scrolling; zoom with pinch (anchored at the focal point), ⌘+scroll (anchored at the cursor), or the toolbar. Tap a node to zoom to it. Fit, + and − frame the true node bounds rather than a guess.
Programmatic transforms apply instantly rather than tweening. That is deliberate and not laziness: the platform suspends the animation frame loop when no gesture is active, so a tween would never tick and the canvas would blank until your next drag woke it. Pinch stays smooth because the gesture itself drives every frame.
The graph only re-lays-out when its change signature moves, so an idle app costs nothing to display.
From a node to its source line
With the Babel plugin installed, every component carries the file, line and column where it is declared, so a node in the graph is one step from the code that produced it. In a development build every element also carries its call site — where this <Button/> is written, rather than the Button.tsx every button shares — which is the line you open to change it. Without the plugin the inspector still works — it shows names, and the source selector has nothing to match on.
Components
The graph answers “what is this doing”. The component side answers “what is this, and where is it written” — the Flutter DevTools idea, navigating by hierarchy or by pointing at the screen, with both roads ending at the line of code.
Pick mode
Press ⌘ ⇧ C, or the button in the device panel's header, and hover the device mirror: the component under the pointer is outlined, the outline springs as you move between elements, and a depth rail beside the pointer names it, its size and its line. Scroll to walk the rail out to a parent and back, and click to select what it has lit. Where components are stacked, the click also opens a list of everything under that point, siblings hidden behind it included; it stays open while you move to it, and ↑ ↓, the scroll wheel and Enter↵ choose from it. ⌘-click opens the call site in your editor. With a selection, ↓ walks out to the parent, ↑ back in, and the side arrows to the siblings. Esc puts things down one at a time — the list, the selection, then pick mode — and the × on the selected element's label clears the selection too. Nothing reaches the app while pick mode is on, and it works on every screen: off the Inspector, a selection shows a toast with Open in Inspector rather than navigating. Pick mode and Flow Studio recording exclude each other.
Hit-testing runs against a rect index held in the desktop app, so a mouse move never costs a round trip to the device.
The tree and Details
The Inspector keeps a component tree down its left side, in a column you can widen by dragging its edge and hide with the button beside the title or ⌘ ⇧ E; a deep tree scrolls sideways as well as down. By default it shows your code: components written outside your app collapse into a +N chip that expands in place, and All shows everything. It searches by name, testID, label, text and file, pulses a row when it re-renders, and offers open call site and open definition on every row. One selection drives everything — the device outline, the tree, the Graph (which frames it) and Perf (which counts renders under it).
With Follow in app on (the eye in the tree's header, on by default), selecting a component in the tree or the Graph brings it on screen in the app: Squiggle navigates to its screen when it is on one you cannot see, then scrolls every list it sits in until it is in view. A selection made on the device never moves the app.
Details, the default tab, shows the selected component the way React DevTools would, refreshed while it re-renders: its props, its hooks and state by the names in your source, the contexts it reads and which component provides each, its style with a box model, its renders and whether React Compiler memoized it, its accessibility and hit-target size, and its structure. The Copy menu hands it on: the selector a flow step would target it by, its source location, or a prompt for an agent.
Editing live
Props and hook state are editable in place — toggle a boolean, or double-click a value — through React's own DevTools interface, so the app re-renders with the new value straight away. An edited row gets a dot and a reset button. Edits last until the component remounts or the app reloads; nothing is written to your code. Agents get the same read over MCP as inspect, and cannot edit.
Opening your editor
Until you choose an editor, Squiggle asks your app's Metro to open the file — the same route React Native's error overlay uses, so it works with no setup. Pin Cursor, VS Code, Windsurf or Zed in the Open in menu and Squiggle runs that editor's own command-line tool from inside its app instead, straight to the column, with no “an external application wants to open” prompt.
The on-device overlay
The same graph, without a desktop app or a network: drop <SquiggleOverlay /> in (or use <Squiggle>, which includes it) and a floating badge expands into a live panel sourced over an in-process loopback.
○ ComponentName ↻12 s- A component, its render count, and why it last rendered —
state,props orforced. ▣ storeName △8 [key, …]- A store, its mutation count, and the keys that last changed.
← Comp, Comp2- The components subscribed to that store — the
subscribes_toedges, rendered inline. file.tsx:42- The source location, when the Babel plugin is installed.
It self-gates: nothing renders outside dev, on the web, or when a WebSocket consumer is configured — the protocol is single-consumer, so the overlay stands down when the desktop or the CLI is attached. Leaving it mounted in a production tree is safe.
The Perf tab
Renders per second, plotted live, with a draggable crosshair that scrubs into the past — so a spike you noticed a moment ago is still inspectable. This is the fast read on “is this screen quiet when nothing is happening”, which is the question a render storm fails.
The Screen map
The fourth tab draws the app one level up: each card is a screen, with its screenshot once a crawl has taken one, and the map reads like a roadmap: the screen the app opens on across the top (with its tab bar's other tabs beside it), and under every screen the screens it opens, one row per tap, joined by a connector labelled with the control that opens them. Screens of one navigator side by side are framed under its name, so a lab's ten screens are one row under the lab. Other ways between screens (a tab strip leading from each lab screen to every other) are left off until you select a screen: then the way to it from the top is drawn in the accent, and its other ways out and in are added. Screens no known tap leads to sit apart, below the roadmap. The screen the phone is on is ringed and follows the app as it moves; a screen nobody has reached is dashed. Zoom with the buttons in the corner, or pinch and ⌘+scroll. The last button toggles, as on the Graph tab: Fit frames the whole map and turns into Focus, which frames the selected screen, or the one the app is on.
- Where it comes from
- Two sources, merged. The first is the routes your app declares, read from React Navigation or Expo Router, including screens nothing has visited yet. The second is the screens and taps that flow runs, agent runs, crawls and you have seen. That second source is the screen memory agents plan routes with, one file per app under
.squiggle/agent/, and each screen's picture is kept under.squiggle/screenshots/screens/. A declared screen no run has reached is drawn as never reached, below the roadmap. Memory keeps itself in line with the app: when a top-level screen is renamed or deleted, the next run or crawl forgets it and the taps into it, and the map drops it as soon as the connected app stops declaring it. A screen inside a navigator that is not open is kept, since the app cannot say yet whether it still exists. A screen is named by its path, so a stack's own first screen (oklch/index) isoklch, never a secondindex. - Screens you open yourself
- While Studio shows a device, every screen its app settles on is checked against memory. One the map does not have yet, such as a screen behind a sign-in form that a crawl cannot get past, is added with its picture as soon as you open it, from the device panel, from Simulator, or during a flow run. When the tap that opened it went through the device panel, the control you tapped becomes the way in from the screen before. A screen the map already has costs one read and is left alone, and nothing is recorded while a crawl is driving the device.
- Uncovered screens
- A screen that no saved flow reaches is marked as having no flow. A flow reaches the screens its latest run stood on, and the panel shows that run's verdict beside it (each run records its screens in
.squiggle/runs/index.jsonl). Before it has run, a flow reaches a screen by naming it, with aprerequisite, anassert: { route }, awaitFor: { route }, anavigate:or a tap'sexpectRoute. It also reaches the screens its taps lead to, following the taps screen memory has seen, so a flow that tapsopen-oklch-labreaches the lab without naming it. - Filters
- All screens, Reachable from here (the screens with a known path from where the app is; when nothing known leads out of the current screen yet, the bar says so) and No flow yet.
Select a screen on the map, or from the Go to screen menu, which a keyboard and a screen reader can reach. The side panel opens with the screen's picture and its actions. Below them it lists the way there from where the app is, one tap per line; the screens it leads to and is reached from, each with the control between them (clicking one selects that screen); the saved flows that reach it; and the testIDs and labels on the screen itself, not the tabs and stacked screens mounted beside it. Clicking one of those copies it as a selector. Close the panel with its × or Esc. The panel has four actions:
- Go there
- Drives the app along the path as a one-step flow run, from where it is, with the same rails and waits as any run, then waits up to five seconds for the screen to arrive. When no path is known, it asks the app's own router. When it does not arrive, it says why.
- New flow to here
- Saves a scripted flow that walks the path from the first screen and asserts the route at the end, then opens it in Flows.
- New agentic flow here
- Saves a flow that starts on this screen, with an
agent:step whose goal you write in plain English, and opens it on the Agentic tab. This is the quickest way to cover a gap. - Crawl from here
- Crawls the app starting on this screen. The crawl presses each control in turn, top to bottom, in the app itself (not with a simulated touch, so a handler that throws is caught rather than showing a crash screen). When a press opens a screen it has not seen, it maps that screen on the spot: it waits until the picture has stopped changing, photographs it and asks its controls, then goes back for the next one. A control repeated across screens, like a tab strip, is pressed once. Declared screens that no button reached are opened through the app's own navigator at the end. A form is filled before its buttons are pressed, with the values your saved flows type into those fields from variables (
value: '${USER}'), so a sign-in signs in and the screens behind it are mapped too. When no flow fills a sign-in, the map says which screen asks for what, and you can sign in on the device yourself. Only native chrome such as a tab bar gets a real tap, and a control named for crashing, throwing, resetting or stressing the app is never pressed. A link that leaves the app (Safari, an in-app browser) is closed again and never pressed twice. The map fills in as each screen is saved. While a crawl runs, the button is Stop crawl: it stops after the control being asked and keeps everything mapped so far, and quitting the desktop app stops it too. What a crawl finds replaces what memory had guessed about the controls it asked. This needs the desktop app.
To fill the map in from a terminal, crawl the app:
# what the map's Crawl button runs: every declared screen, photographed,# and what each control openssquiggle crawl --tour# start the crawl on one screensquiggle crawl --tour --from oklch/wide# after a change: revisit only the screens made of the files it touchedsquiggle crawl --changed--tour runs the same crawl as the map's button, within budgets of 80 screens and five minutes (--max-nodes and --duration change them; the map's button allows ten). Ctrl-C stops it the same way the map's Stop does. It writes what it found to the screen memory the map reads and lists the declared screens it could not open (a screen that needs parameters, like [id], is never guessed at). --no-thumbnails skips the photographs. --from takes a screen's path or name. A plain squiggle crawl is the random crash hunt it always was, and --complete runs it to exhaustion. On squiggle.sh, the map is drawn from a route graph committed with the demo app, so it works without a crawl.
Arriving from an insight
A performance finding names the component it blames. Clicking its evidence chip in the Performance Center navigates here with that node focused — the insight-highlight path. If the recorded node is not in the live graph any more, you get an inline notice saying so rather than an empty canvas or a crash.
Querying it instead
Everything the inspector draws is also a query. The graph is projected into SQLite as it streams, so the same facts are available without a UI — which is how the CLI and agents read them:
# every component that re-rendered more than 20 timessquiggle perf sql " select node_id, count(*) as renders from renders where session_id = (select id from sessions order by started_at desc limit 1) group by node_id having renders > 20 order by renders desc"An agent gets the same thing structurally through query over the live graph, or probe_read to filter server-side and return only what it needs. See Agents & MCP.