Field guide

Vibe debugging when your app has state

A static page is easy to debug: it renders or it throws. The moment your app holds a value (a counter, a saved list, a multi-screen flow) the bugs move. They appear on the second click, after you type something, after a refresh. And almost none of them throw an error you can paste. This is how to debug that.

M
Matthew Diakonov
7 min read
Direct answer (verified 2026-05-19 against the appmaker repo)

State-dependent bugs (a counter that resets, a list that vanishes on refresh, the wrong screen after navigation) usually throw no error, so the paste-the-stack-trace loop never sees them. You debug them by describing the state path in plain words: the starting state, the actions in order, expected versus actual. The agent walks that path against your running app and watches the bug itself. mk0r is built so this works: a hot reload (waited for up to HMR_WAIT_MS = 800 in src/components/phone-preview.tsx) repaints edits without wiping in-memory state, a persistent Chromium profile keeps localStorage reachable across turns, and every turn is a git commit you can step back to.

The bug you cannot paste

Most writing about vibe debugging assumes a crash. Something throws, a red overlay appears, you copy the stack trace, you hand it to the AI, it fixes the line. That loop is real and it works, for crashes.

State bugs are not crashes. The app renders. The bundler is happy. The browser console has nothing in it. A piece of code ran and produced a value, and the value happens to be wrong: the cart total is off by one item, the toggle shows on when it should be off, the notes you typed are gone after a refresh. Mechanically nothing failed, so there is no error object to find. The standard advice has nothing to feed the AI.

So the debugging input has to come from you, and it has to be a description of behavior rather than a string from a log. The rest of this guide is about making that description precise enough to act on, and about why the tool you debug in either keeps the broken state reachable or destroys it.

Three kinds of state, three different bugs

"State" is not one thing, and the reason your app broke depends on which kind you are dealing with. Naming the kind is the first real step, because the debug move is different for each.

In-memory

useState, useReducer, refs

A counter, the open modal, the current step, an unsaved form input. Lives in RAM, scoped to the component tree.

What breaks: It resets when it should not (state lost on a re-mount), or it holds a stale value because two pieces of state drifted out of sync.

Debug move: Reach the state without reloading. Describe the click path; the agent edits over live HMR so the tree is not unmounted.

Persistent

localStorage, cookies, IndexedDB

Theme choice, a draft saved between visits, a logged-in session. Survives a reload because it lives in the browser, not the tree.

What breaks: It persists when it should not (stale data sticks), or the read and write shapes drifted so old saved data no longer parses.

Debug move: Reproduce it. The in-VM Chromium keeps its profile across turns, so your saved entry is still there for the agent to load.

Server

A real database

Data that must outlive the browser entirely, or be shared between people and devices.

What breaks: It is missing. The app only ever had in-memory state, so the data evaporates on refresh because it was never written anywhere durable.

Debug move: Not a patch. The agent's named trigger provisions Neon Postgres for any feature where in-memory state would be lost.

The most common mistake is treating all three the same way: hunting for a code bug when the real answer is that the app has no persistence layer at all, or reloading to "reset and try again" when the state you need lived in memory and the reload just deleted it.

Why reloading to debug makes it worse

Here is the trap. A state bug exists because the app is in a particular state: three items added, two tabs switched, a form half-filled. The instinct when something looks wrong is to reload. But a reload unmounts the React tree, and every useState value goes with it. You reload, the counter is back to zero, and the bug is "gone", until you click your way back to reproduce it. Worse, if the agent reloads, snapshots a clean fresh-load page, and sees nothing wrong, it can report the bug fixed when it is still there.

mk0r's preview is built so debug edits do not force that reset. When the agent writes a file, the preview does not hard-reload first. It waits up to HMR_WAIT_MS, set to 800 in src/components/phone-preview.tsx line 32, for Vite's hot module replacement to repaint. A successful hot reload swaps the new code into the running tree without unmounting it, so your counter keeps its value and you stay on the broken screen while the fix lands. Only if HMR fails to paint within 800 milliseconds does the iframe fall back to a hard reload.

Persistent state is handled by a different mechanism. The in-VM Chromium runs with a persistent profile at /root/.chromium-profile/. Per the agent's instructions in src/core/vm-claude-md.ts lines 275 to 276, cookies, localStorage, and login sessions persist within the session. So a bug like "my saved theme reverts after a refresh" is reproducible across debug turns: the agent loads the page and the localStorage entry you created is still sitting there for it to inspect.

And every debug turn is a checkpoint. The commitTurn helper in src/core/e2b.ts runs a real git commit after every turn that changed a file, and pushes the SHA onto a history stack. A debug attempt that fixed the wrong thing is one git checkout away from gone.

src/core/e2b.ts

The debug loop for a state bug

With a crash you start from the error. With a state bug you start from a description, and the loop is shaped around getting the agent into the same broken state you were in.

Describe the state, reach it, fix it, verify in place

1

You describe the state path

Not the symptom. The starting state, the actions in order, expected versus actual. A path the agent can replay.

🌐

The agent walks the path on your running app

It navigates to localhost:5173 via Playwright, takes a DOM snapshot, and checks browser_console_messages (vm-claude-md.ts:278-283).

The fix lands over live HMR

A hot reload repaints the edit without unmounting the tree, so the in-memory state you described is still there to verify against.

4

The turn becomes a commit

git commit -q snapshots the code state. If the fix was wrong, the previous SHA is one checkout away.

The dangerous one: data that should persist and does not

The worst state bug is not a crash and not a wrong value. It is silent data loss. You build a notes app, add five notes, refresh, and they are gone. There is no bug to fix in the code that exists, because the notes only ever lived in useState, which is RAM. The app was never told to write anything durable. The "bug" is a missing persistence layer wearing a bug costume.

mk0r's agent has a named trigger for exactly this. Its in-VM instructions in src/core/vm-claude-md.ts line 1224 say to provision a database for "any feature where in-memory state would be lost." So when you describe "my data disappears on refresh," the right move is not a patch to the existing code. It is recognizing the app needs real persistence, and the agent provisions Neon Postgres for it.

The honest exception: if you are building a throwaway prototype to demo an idea this week, in-memory state is completely fine, and you should not add a database for it. The data loss only counts as a bug when the data is genuinely supposed to last. Part of debugging a state issue is deciding which of those two situations you are actually in before you ask for a fix.

How to describe a state bug so the AI can reproduce it

The fix quality is bounded by whether the agent can reach the broken state. So the description is the whole game. Three parts, in plain words:

  1. 1. Name the starting state. Where the app is before the bug. "On the Today tab with two tasks already added." Not "on the app."
  2. 2. Name the actions, in order. The exact sequence. "Tap Add, type a third task, tap the History tab, tap back to Today." Order matters because state bugs are usually about sequence.
  3. 3. Name expected versus actual. "I expected three tasks on Today. I see zero. The list is empty." Both halves, so the agent knows what correct looks like.

Compare the two inputs. "The task list is broken" sends the agent to an app where the list looks fine on fresh load, and it finds nothing. The three-part version is a script it can replay click for click, land in the same empty list, and debug from there. You are not writing a bug report for a human triage queue. You are handing the agent a path to a specific state.

What this does not cover

The honest framing: this loop handles a class of state bug, not all of them. Three kinds sit outside it.

  • State that needs more than one real user. Race conditions between two people editing the same record, presence indicators, anything that only breaks under concurrent traffic. The sandbox runs one Vite and React frontend in one browser. That state is not present, so it cannot be reproduced here.
  • Logic that is wrong but does not look wrong. A total that renders cleanly and adds incorrectly. A timer that counts at the wrong rate. The agent verifies what the DOM and the console can show; a plausible-looking wrong number still needs you to notice it.
  • State from a session that already ended. In-memory state and the console buffer are scoped to the current iframe load. A bug you saw two days ago, in a state you can no longer reconstruct, has no echo for the agent to read. You have to rebuild the path.

Stuck on a state bug you cannot describe?

Open mk0r.com, reproduce it once, and we can walk the state path together to get the agent into the broken screen.

Frequently asked questions

Why don't state bugs show up as errors I can paste to the AI?

Because nothing crashed. A crash bug throws: a stack trace lands in the console, the preview overlay turns red, you have a string to copy. A state bug is different. The app renders, the bundler is happy, the browser console is clean. The app just does the wrong thing once it holds a value: the counter resets when you switch tabs, the saved list comes back empty after a refresh, the form shows step two when you expected step three. There is no error object anywhere because, mechanically, nothing failed. A piece of code ran and produced a result that happens to be wrong. That is why the common advice for vibe debugging (wait for the error, paste it, let the AI fix it) has nothing to work with here. You have to supply the description yourself.

What does 'describe the state, not the error' actually mean?

It means you stop reporting the symptom and start reporting the path that produces it. 'The counter is broken' gives the agent nothing reproducible; it can open your app, see a counter at zero, and find nothing wrong. 'Add three items, open the History tab, come back, and the list shows zero items when it should still show three' is a path. The agent can walk that exact path against your running app and watch the same wrong result you watched. Name three things: the starting state, the actions in order, and what you expected versus what you got. The fix quality is bounded entirely by whether the agent can reach the broken state, and a path is what gets it there.

Does editing the app reset its state every time, so I lose the bug while debugging?

Not for in-memory state, and that is deliberate. The instinct when something breaks is to reload, but a reload unmounts the React tree and throws away every useState value, which is the exact thing the bug depends on. In mk0r, when the agent edits a file the preview does not hard-reload first. It waits up to HMR_WAIT_MS, set to 800 in src/components/phone-preview.tsx line 32, for Vite's hot module replacement to repaint. A successful hot reload swaps the new code into the running tree without unmounting it, so your counter keeps its value and you stay on the broken screen. Only if HMR fails to paint within 800 milliseconds does the iframe fall back to a hard reload. So most debug edits land without resetting the state you are trying to inspect.

My data disappears when I refresh the app. Is that a bug or a missing feature?

Usually a missing feature wearing a bug costume. If your notes vanish on refresh, there is often nothing to fix in the code that exists, because the notes only ever lived in useState, which is RAM. The app was never told to persist anything. mk0r's agent has a named trigger for this: its in-VM instructions (src/core/vm-claude-md.ts line 1224) say to provision a database for 'any feature where in-memory state would be lost.' So the correct move when you describe 'my data disappears on refresh' is not a patch, it is recognizing the app needs a real persistence layer (Neon Postgres) and provisioning one. The honest exception: if you are building a throwaway prototype, in-memory state is fine and you should not add a database. The data loss only matters when the data is supposed to last.

Can mk0r reproduce a bug that only happens after I log in or set something in localStorage?

Yes, within a session. The in-VM Chromium runs with a persistent profile at /root/.chromium-profile/, and per the agent's instructions (src/core/vm-claude-md.ts lines 275 to 276) cookies, localStorage, and login sessions persist within that session. So a bug like 'after I save preferences and reload, the theme reverts' is reproducible: the agent can load the page and the localStorage entry you created is still there. Its verification checklist (lines 278 to 283) is to navigate to localhost:5173 via Playwright, snapshot the DOM, check browser_console_messages, and not report completion until the page shows the expected result. What it cannot reproduce is state that needs a fresh second browser or real concurrent users, which is a different class of bug entirely.

If a debug attempt makes the app worse, can I undo it?

Byte-exactly. Every turn the agent runs becomes a real git commit. commitTurn in src/core/e2b.ts (line 2099) runs git add -A then git commit -q (line 2113) against the project repo in /app, and pushes the resulting SHA onto a history stack on the session. A debug turn that fixed the wrong thing, or introduced a new state bug while chasing the old one, is one git checkout away from gone. revertToSha (line 2171) runs git checkout against a known SHA. So you can experiment with a fix, see it make the broken screen worse, and step the code back to exactly the bytes from before that turn.

Why is reloading the first thing not to do when debugging a state bug?

Because reloading is the move that destroys the evidence. A state bug exists because the app is in a particular state: three items added, two tabs switched, a form half-filled. Reload, and in-memory state is gone, the app is back at its fresh-load state, and the bug appears fixed. It is not fixed, it is just not currently visible, and you have to click your way back to reproduce it. Reload also wastes the agent's verification: if it reloads, snapshots a clean fresh-load page, and sees nothing wrong, it can report completion on a bug that is still there. The discipline is to reach the broken state and inspect it in place, which is why mk0r's HMR-first preview matters: it lets edits land without forcing that reset.

mk0r.AI app builder
© 2026 mk0r. All rights reserved.

How did this page land for you?

React to reveal totals

Comments ()

Leave a comment to see what others are saying.

Public and anonymous. No signup.