Why I Don't Trust the Submit Button Toggle

Shibin Das avatar
Shibin Das

Here’s a pattern I’ve written more times than I care to admit:

button.classList.add('enabled');

button.addEventListener('click', async () => {
  button.classList.toggle('enabled');   // intent: disable while submitting
  await sendRequest();
  button.classList.toggle('enabled');   // intent: re-enable after
});

Two toggles. Symmetric. Looks self-explanatory.

Wrong in a way that takes a paragraph to explain and ten minutes to debug the first time you ship it.

The problem isn’t the function name. It’s the operation.

toggle() says “flip whatever’s there.” add() and remove() say “make it on” or “make it off.” Those look interchangeable when you’re typing them. They aren’t — not when something else writes to the class between your two calls.

This post is about why I avoid toggle(), and why fixing the spelling1 isn’t enough.


Toggle Promises Less Than Set

Look at the first toggle('enabled') and ask: what does it actually do?

You can’t tell. Not without knowing what the class was a moment ago.

If enabled was on, the toggle removes it — disabling the button, like we wanted. If enabled was off (because a validation handler removed it half a second earlier when the user typed something invalid), the toggle adds it back — enabling the button at the exact moment we wanted to disable it.

Same line of code. Opposite behaviour. The difference lives entirely in the program’s state just before the line ran.

That’s a weak contract. The line is asking the reader to mentally replay everything that happened before this point to know what it does. The line itself carries no intent. It just says flip.

Compare:

button.classList.remove('enabled');
await sendRequest();
button.classList.add('enabled');

Each line asserts a state. You can read these three lines cold, with no surrounding context, and tell me what the program intends: disable, do the thing, enable. The meaning lives in the line — not in the history behind it.

That’s a strict upgrade. toggle() is never more legible than add() / remove(). Often it’s less. So I avoid it.

That’s the small version of the principle. It’s also not enough.


And Then the Network Fails

Forget hypothetical races for a second. The original code has a more boring bug — one that fires under the most expected failure mode in any networked app.

Look at it again:

button.addEventListener('click', async () => {
  button.classList.toggle('enabled');   // 1. remove enabled
  await sendRequest();                   // 2. THROWS
  button.classList.toggle('enabled');   // 3. never runs
});

If sendRequest() rejects — network down, server 500, CORS, anything — the promise rejects, the async function unwinds, and the second toggle never runs. The button is stuck visually “disabled” forever.

And because we never actually set button.disabled, the button is still clickable. So the user retries:

  1. Failed request leaves the button visually disabled.
  2. User clicks. Handler fires.
  3. toggle('enabled') adds it back. Looks enabled.
  4. await sendRequest() succeeds this time.
  5. toggle('enabled') removes it. Looks disabled again.

The retry succeeded. The button looks disabled. The user clicks again. The next request succeeds. Disabled again. The visual state oscillates with no relationship to whether the request worked.

This is the bug you ship. You won’t catch it in development — your local server doesn’t reject requests. You’ll catch it three days after launch when the first real flaky network event hits production.

A try / finally patches this specific instance. Which brings us to the next problem: even with try / finally in place, the architecture is still broken.


But Set Is Wrong Too

Switching to add() / remove() fixes legibility. Wrapping the async call in try / finally patches the network-failure bug. We’ve climbed one rung, taped over one obvious crash, and the underlying architecture is still broken: more than one piece of code is allowed to write the class.

Picture the full handler with absolute writes (and the finally block this time):

form.addEventListener('input', () => {
  if (isValid(form)) {
    button.classList.add('enabled');
  } else {
    button.classList.remove('enabled');
  }
});

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  button.classList.remove('enabled');
  try {
    await sendRequest(form);
  } finally {
    button.classList.add('enabled');
  }
});

The user edits a field while sendRequest is in flight. The input handler runs. The form is now invalid, so enabled comes off. The request resolves. The finally block adds enabled back.

The button is enabled. The form is invalid. The user clicks. We submit garbage.

This looks like the toggle() bug. It isn’t quite. The toggle() bug is operation-level: one call doing the opposite of what we meant. This one is architecture-level: two code paths with private opinions about whether enabled belongs on the button, both writing into the same cell, last write wins.

toggle() is a footgun. set() is a slower footgun. Same trigger in both — more than one writer, no canonical source.


The Toggle Is a Cache

Time to rename what we’re looking at.

button.classList.contains('enabled') isn’t really a state. It’s a cache of a question: given the form, the network, and the user’s permissions, should this button accept clicks?

Every add('enabled') writes to that cache. Every remove('enabled') writes to that cache. toggle('enabled') writes to it too — just with worse spelling.

Caches are useful when the underlying computation is expensive. The computation behind a submit button isn’t expensive. It’s isValid && !isSubmitting && hasPermission, and it costs nanoseconds.

The cache isn’t buying you performance. It’s buying you a mental model that feels procedural — first I disable, then I send, then I enable.

What you pay for that feeling is cache invalidation. Phil Karlton’s old joke — that cache invalidation is one of computing’s two hardest problems — isn’t really about Redis. It’s about the fact that the moment you have two copies of one truth, every new code path becomes a chance to desync them.

A toggle isn’t a state. It’s a cache. Caches must be invalidated. You didn’t sign up for that — but you did, the moment you wrote the second classList.toggle().


The Cure: Implicit Set, Implicit Unset

Stop writing enabled from anywhere. Make the button’s class a projection of the form’s data, not a thing the form pokes at:

function refreshSubmitButton() {
  const ok = isValid(form) && !isSubmitting && userCanSubmit;
  button.classList.toggle('enabled', ok);
  button.disabled = !ok;
}

form.addEventListener('input', refreshSubmitButton);

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  isSubmitting = true;
  refreshSubmitButton();
  try {
    await sendRequest(form);
  } finally {
    isSubmitting = false;
    refreshSubmitButton();
  }
});

Quick note on the apparent irony: classList.toggle('enabled', ok) with a second argument isn’t a flip anymore. The second argument is the target state. true adds, false removes. It’s add() / remove() chosen at runtime, in one line.

The point was never to ban a function name. The point was to never assert “flip whatever’s there” from inside application logic — and never maintain enabled from more than one place.

Now there’s one piece of code that writes the class. It’s a pure function of three named things: isValid(form), isSubmitting, userCanSubmit. The earlier bug can’t happen, because no code path is allowed to assert “enable this” or “disable this” on its own. The worst the input handler can do is call refreshSubmitButton — and that will compute the right answer no matter when it runs.

A quiet bonus: the finally block runs on both success and failure, so a network error can never leave the button stuck. But even if finally somehow didn’t run — promise hangs, tab backgrounds, something exotic — the next user keystroke would call refreshSubmitButton() and recompute the correct state from the form’s truth. The cure self-heals from arbitrary state corruption. The toggle accumulates damage.

That’s what I mean by implicit set, implicit unset.

The button is never explicitly set or unset by the application. It’s always correct by construction, given whatever the form says. The word “toggle” stops describing what the application does and starts describing what the DOM does, internally, after my code has already decided.


What the Whole Thing Is About

I started by saying I don’t trust toggle(). That’s the small version of the claim.

The bigger version: I don’t trust any line of code that writes a piece of UI state imperatively when that state is derivable from data I already have.

toggle() is the worst version of the pattern because it hides intent behind program history — you can’t read the line in isolation. add() / remove() is the second-worst because it makes the intent legible but still keeps a mirror that anyone can write to. The form I actually want is the one where the button has no state of its own. Only a reading of facts that live elsewhere.

toggle() is the visible symptom — the one that made me notice. The disease is the mirror.

Treat the disease and the spelling stops mattering. Use classList.toggle('enabled', ok). Use button.disabled = !ok. Use whatever your framework gives you. The test isn’t which function you called. The test is whether anyone else in the program could have called a different one and put the button into a state your code doesn’t know about.

If the answer is no, you have implicit set and implicit unset.

And the button is finally telling the truth.


  1. “Spelling” is shorthand throughout this post for which DOM method you typedtoggle() vs add() vs remove(). They’re different spellings of the same underlying act: writing to the button’s enabled class. The argument is that the method you pick is a surface detail — a symptom — and swapping one for another doesn’t touch the real problem underneath. ↩︎

Shibin Das

Created by Shibin Das

Drupal Developer. Spice Dealer. Prying on Information Architecture nowadays.

Recommended for You

Lets talk!

Get in touch with me for sharing your ideas. Who knows what our next adventure would be!