- I use Cursor + Claude to generate state diagrams, not random code.
- I convert UI states into a typed reducer. No more “boolean soup”.
- I add 1 tiny invariant test that catches most regressions.
- Works for Next.js, React, and even boring CRUD screens.
Context
I ship a lot of small SaaS apps. Usually solo. Usually fast.
And my UI bugs weren’t “hard.” They were dumb. The kind that shows up only after you add one more button.
Stuff like: loading spinner stays forever. Save button enables while a request is in flight. Error banner shows on the success screen. Or the classic: isLoading is false but you’re still “saving”.
I tried the usual.
A couple useStates. Then more. Then a useEffect to “sync” them. Brutal. Spent 4 hours on this once. Most of it was wrong.
Now I treat UI like a state machine. Not a library. Just a reducer and strict events.
Cursor + Claude help me do it fast. The trick: I don’t ask for code first. I ask for states.
1) I write the states down. Then I force Claude to agree
I start in Cursor. New file. Something like ui-state-notes.md.
I describe the screen in plain English. Then I ask Claude for a state table.
Not “write React code.”
I want a list of states and events. If the list is wrong, code won’t save me.
Here’s the exact prompt style I use (you can paste this into a Cursor chat). It’s boring on purpose.
You are my reviewer.
Given this UI, produce:
1) a list of mutually exclusive states
2) allowed events per state
3) forbidden transitions
4) 3 invariants that must always hold
UI:
- form with 3 fields
- Save triggers API call
- can show validation errors
- can show server error toast
- after success, show “Saved” and disable Save for 2s
No code yet. Only the table.
Then I sanity-check it like a grumpy QA.
If Claude gives me 12 states for a simple form, I push back. If it forgets “submit while already submitting”, I push back.
One thing that bit me early — Claude loves adding a retrying state. I almost never need it. I model retry as an event, not a state.
Once the table looks right, I freeze it. That’s my spec.
2) I kill boolean soup with a typed reducer
Most flaky screens I’ve seen have the same smell:
isLoadingisSavinghasErrorshowSuccessdisabled
Five booleans. Thirty-two combinations. Half of them nonsense.
So I replace them with a discriminated union and a reducer.
This snippet is copy-paste ready. Plain React. Works in Next.js App Router too.
import * as React from "react";
type State =
| { tag: "idle"; values: Record; fieldErrors: Record }
| { tag: "submitting"; values: Record }
| { tag: "success"; values: Record; lockedUntil: number }
| { tag: "serverError"; values: Record; message: string };
type Event =
| { type: "CHANGE"; name: string; value: string }
| { type: "SUBMIT" }
| { type: "VALIDATION_FAILED"; fieldErrors: Record }
| { type: "SUBMIT_OK"; now: number }
| { type: "SUBMIT_ERR"; message: string }
| { type: "UNLOCK"; now: number };
export function reducer(state: State, event: Event): State {
switch (state.tag) {
case "idle": {
if (event.type === "CHANGE") {
return {
tag: "idle",
values: { ...state.values, [event.name]: event.value },
fieldErrors: { ...state.fieldErrors, [event.name]: "" },
};
}
if (event.type === "SUBMIT") return { tag: "submitting", values: state.values };
if (event.type === "VALIDATION_FAILED") return { ...state, fieldErrors: event.fieldErrors };
return state;
}
case "submitting": {
if (event.type === "SUBMIT_OK") return { tag: "success", values: state.values, lockedUntil: event.now + 2000 };
if (event.type === "SUBMIT_ERR") return { tag: "serverError", values: state.values, message: event.message };
// Ignore changes while submitting. This is a product choice.
return state;
}
case "success": {
if (event.type === "UNLOCK" && event.now >= state.lockedUntil) {
return { tag: "idle", values: state.values, fieldErrors: {} };
}
return state;
}
case "serverError": {
if (event.type === "CHANGE") {
return {
tag: "serverError",
values: { ...state.values, [event.name]: event.value },
message: state.message,
};
}
if (event.type === "SUBMIT") return { tag: "submitting", values: state.values };
return state;
}
}
}
Notice what’s missing.
No isLoading. No showToast. No disabled state.
I derive UI behavior from state.tag.
And yeah, it feels slower at first. Then the second screen takes 15 minutes.
3) I wire the reducer to real async code (without useEffect spaghetti)
This is the part where I used to mess up.
I’d do:
- set loading true
- await
- set loading false
- set success
Then a request throws, and half the setters don’t run.
Reducer fixes that.
I dispatch events around the async boundary. One entry. One exit.
import * as React from "react";
import { reducer } from "./reducer";
type SaveInput = { values: Record };
async function saveToApi(input: SaveInput): Promise {
const res = await fetch("/api/save", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
// Common: API returns { message }
const data = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(data.message ?? `Request failed: ${res.status}`);
}
}
export function SettingsForm() {
const [state, dispatch] = React.useReducer(reducer, {
tag: "idle",
values: { name: "", email: "", company: "" },
fieldErrors: {},
});
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (state.tag !== "idle" && state.tag !== "serverError") return;
// Tiny validation example. Keep it deterministic.
const fieldErrors: Record = {};
if (!state.values.email.includes("@")) fieldErrors.email = "Invalid email";
if (Object.keys(fieldErrors).length) {
dispatch({ type: "VALIDATION_FAILED", fieldErrors });
return;
}
dispatch({ type: "SUBMIT" });
try {
await saveToApi({ values: state.values });
dispatch({ type: "SUBMIT_OK", now: Date.now() });
} catch (err) {
dispatch({ type: "SUBMIT_ERR", message: err instanceof Error ? err.message : "Unknown error" });
}
};
React.useEffect(() => {
if (state.tag !== "success") return;
const t = setInterval(() => dispatch({ type: "UNLOCK", now: Date.now() }), 200);
return () => clearInterval(t);
}, [state.tag]);
const disabled = state.tag === "submitting" || (state.tag === "success" && Date.now() < state.lockedUntil);
return (
dispatch({ type: "CHANGE", name: "name", value: e.target.value })}
disabled={disabled}
/>
dispatch({ type: "CHANGE", name: "email", value: e.target.value })}
disabled={disabled}
/>
{state.tag === "submitting" ? "Saving…" : "Save"}
{state.tag === "serverError" && {state.message}}
{state.tag === "success" && Saved
}
);
}
Yes, there’s a useEffect.
But it’s not “sync booleans.” It’s only for the timed unlock. And it’s scoped to one state.
Also. That disabled line is boring.
Boring is good.
4) I add one invariant test. It catches the dumb stuff
I don’t write a full test suite for every form. I’m not a hero.
But I do add one invariant test for reducers. Because reducers are pure. Cheap to test.
This one checks a rule I’ve broken multiple times: you can’t be success and submitting in the same flow, and success must have a future lockedUntil.
Node has a built-in test runner now. No Jest needed.
Create reducer.test.ts and run node --test --import tsx reducer.test.ts.
import test from "node:test";
import assert from "node:assert/strict";
import { reducer } from "./reducer";
test("success state always has a 2s lock", () => {
const s1 = { tag: "submitting" as const, values: { name: "a" } };
const now = 1712012345678;
const s2 = reducer(s1, { type: "SUBMIT_OK", now });
assert.equal(s2.tag, "success");
if (s2.tag === "success") {
assert.equal(s2.lockedUntil, now + 2000);
}
});
test("SUBMIT_OK is ignored outside submitting", () => {
const s1 = { tag: "idle" as const, values: { name: "a" }, fieldErrors: {} };
const s2 = reducer(s1, { type: "SUBMIT_OK", now: 10 });
assert.deepEqual(s2, s1);
});
That second test matters.
I once dispatched SUBMIT_OK from a stale promise after the user navigated away. UI went into a fake success state. Took me an hour to reproduce.
Now the reducer just ignores nonsense.
Results
On my last 3 screens built this way, I shipped with 4 reducer states (idle, submitting, success, serverError) and 6 events.
Before this approach, the same screens usually ended up with 7 booleans and 2 useEffects “keeping things in sync”. That’s where the bugs lived.
Also: debugging got faster. When something’s wrong, I log state.tag and the last event. That’s it. No more staring at React DevTools watching five flags flip.
Key takeaways
- Don’t ask Claude for code first. Ask for a state/event table.
- If you can’t explain the states in 60 seconds, the UI is too complex.
- Replace multiple booleans with a discriminated union. Every time.
- Dispatch events around async boundaries. One entry. One exit.
- Add one invariant test for the reducer. Pure functions deserve cheap tests.
Closing
If you’re using Cursor + Claude, try this once.
Make Claude list states and forbidden transitions. Then write the reducer yourself, fast, inside Cursor.
What’s the one UI flow you keep breaking — settings form, onboarding, checkout, or something else — and what are the states you’d put in your reducer?
Top comments (0)