Systems

The button that lies

6 min

A button with no loading state isn't finished. It's in production with an open bug.

You submit a form. The page doesn't respond. You click again.

You've just created two identical requests.

It's not a server bug. It's a decision nobody made.

The state that's always missing

A button component has six minimum states: default, hover, active, focus, disabled, loading.

Most design systems document four. The two that are missing are the ones that matter most in production.

The loading state happens at the most critical moment of the interaction: right after the user made a decision. It's the only state the user can't easily trigger again if it's lost. And it's the only one that protects the server from receiving the same request three times because the interface never signalled that something was happening.

At Vocento, when I built EVOLOK — the design system for 13 different brands — this was exactly one of the structural problems. The checkout components were built differently in every team. No shared states, no loading tokens, no clear contract between design and code about what the button does after the first click. A/B testing was statistically invalid because each variant behaved differently. Not visually — behaviourally.

A design system that doesn't specify the loading state of its buttons isn't a design system. It's a style library.

And this isn't a media-checkout problem. It's the same bug in the "Save" button of an internal dashboard, in "Create project" in a management app, in "Confirm payment" in a B2B checkout, in the submit button of any form that fires a network request. The component is the same. The bug is the same. Only the business context changes.

Why it happens

In most projects the loading state isn't drawn in Figma because "the backend handles it". The developer implements it however they can — or doesn't implement it at all. The result reaches production without anyone reviewing it, because nobody specified it.

The JavaScript flag that controls whether the button is processing is trivial to implement. What isn't trivial is the design system requiring it:

// No control — the button accepts unlimited clicks
button.addEventListener('click', async () => {
  await submitForm();
});

// With a flag — disables immediately, restores when done
let isLoading = false;

button.addEventListener('click', async () => {
  if (isLoading) return;

  isLoading = true;
  button.disabled = true;
  button.setAttribute('data-state', 'loading');

  try {
    await submitForm();
    button.setAttribute('data-state', 'success');
  } catch {
    button.setAttribute('data-state', 'error');
  } finally {
    isLoading = false;
    button.disabled = false;
    button.setAttribute('data-state', 'default');
  }
});
The difference isn't the try/catch. It's the line if (isLoading) return.

The difference isn't the try/catch. It's the line if (isLoading) return. Without that line, the button accepts every click that arrives while the request is in flight. It doesn't matter whether that request creates an order, saves a record, or triggers a payment — the bug is identical.

The gap between Figma and production

The problem isn't that nobody documents the loading state. It's that the documentation depends on someone remembering to do it.

At Vocento I built a pipeline that removed that dependency.

The flow was simple in concept and hard to maintain without automation: design in Figma with precise autolayout → Cursor with a specific skill for the type of component being built → the agent builds and documents at the same time → MD files with the complete spec → Confluence as the source of truth for teams that didn't coordinate directly with each other.

The critical point is that the agent documented everything it built. Not as an optional step — as part of the same process. If the component had a loading state, that state appeared in the MD. If it didn't, the MD made it obvious because the spec was incomplete.

Developers didn't receive a Figma link and an explainer call. They received a reference implementation in code and the documentation of every decision made during the build.

The loading state stopped being something someone had to remember to specify. It became something the system made impossible to skip.

That's the difference between a design system and infrastructure design. And it's the same difference between a button that works in the demo and a button that survives production at scale — whether it's an ecommerce checkout, a SaaS onboarding form, or the deploy button of an internal dashboard.

The rule

Any action that triggers a network request disables its trigger immediately.

Not after the response arrives. Immediately.

The time between click and disable is the time the interface lies about its state.

The problem, and the solution

Same button, same 2-second request. On the left there's no state control: every click adds a request. On the right, one line — if (isLoading) return — changes everything. Try them.

The six states

Pin each state and inspect it at your own pace. Focus can also be triggered with a real keyboard Tab.

The pipeline

From Figma to Confluence. Walk through each step to see where every decision gets documented.

Before and after the handoff

The same work, two ways to deliver it. Simulate each column and watch where the loading state appears — or disappears.

The component spec

This is what a developer receives when the system works: the complete documentation, with the loading state already in it.