← Back to articles
Technology

Make Impossible UI States Unrepresentable

Make Impossible UI States Unrepresentable

What "Impossible State" Bugs Look Like in the Frontend

"The button is still clickable while the form is loading." "After an error, the screen keeps showing stale data as if nothing failed." These bugs are rarely a logic mistake — more often, they come from under-designed state.

Most UI moves through four states:

  • idle — nothing happening yet
  • loading — a fetch or submission in progress
  • success — the request completed
  • error — the request failed

When you track these as independent booleans (isLoading, isError, data), the type system has no way to stop combinations that should never happen — like isLoading: true and isError: true at the same time. You only discover it when a spinner and an error message render together in the browser.

Three Patterns That Show Up Over and Over

1. Loading state that doesn't block a second click

If a submit button is only guarded by disabled={isLoading}, a timing gap between the click and the state update (right around an await) can let a user click twice before the flag flips.

2. Stale data surviving an error

On a list refetch failure, setting error without clearing data leaves the old list and the error message on screen at the same time. Users can't tell if the action succeeded or failed.

3. An error message that outlives a successful retry

A retry succeeds, but the code forgets to reset the previous error state, so the message keeps showing next to a working screen.

All three share the same root cause: state is modeled as a set of independent variables instead of a single value.

Make the Combination Impossible to Type

The fix is to collapse state into a single discriminated union instead of separate flags.

type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

function Report({ state }: { state: FetchState<BugReport[]> }) {
  switch (state.status) {
    case "idle":
      return null;
    case "loading":
      return <Spinner />;
    case "success":
      return <List items={state.data} />;
    case "error":
      return <ErrorMessage text={state.message} />;
  }
}

In the "error" branch, state.data simply doesn't exist as far as TypeScript is concerned — you can't accidentally reach for stale data even by mistake. The bug gets caught at compile time instead of in a bug report.

Skip the default case and add an exhaustiveness check with never instead, so adding a new state later fails to compile until every branch handles it.

function assertNever(x: never): never {
  throw new Error(`Unhandled state: ${JSON.stringify(x)}`);
}

A Checklist for Migrating Existing Code

You don't need to rewrite every useState at once. Start with screens that match these signs:

  • □ Three or more independent state variables (isLoading, isError, data...)
  • □ Past bug reports describing a loading spinner and an error shown together
  • □ The screen renders the result of an async API call
  • □ Reset logic (setError(null), etc.) is duplicated in multiple places

Migrate one screen at a time and rewrite the rendering as an exhaustive switch — you'll often find a case nobody had actually considered.

Putting This Into Practice with Bugoon

How a state bug gets captured matters as much as how it gets fixed. With the Bugoon widget embedded in your app, QA and non-engineers can annotate a screenshot the moment they notice something odd — a bug where a spinner and an error message appear together is often faster to explain with an image than with words.

Bugoon also records the interaction steps leading up to the report, so developers can trace exactly which action triggered the impossible state. Reports flow into a GitHub Issue, get tracked on a kanban board, and can be handed directly to Claude Code or Cursor through the MCP server for a fix attempt.

If a report could also capture which state (loading, error, or success) the screen was actually in, reproducing state bugs would likely get even faster. Paired with the discriminated-union approach above, there's still room to shrink "impossible states" from the reporting side too.

Streamline bug reporting for your team.

Bugoon is free to get started. Add one line of code to your site and transform how your team handles bugs.

Get Started