# Why I Don't Trust the Submit Button Toggle

> The day I stopped writing classList.toggle('enabled') around an async submit and started deriving the button's state from the form. A walk through why toggle is a weaker promise than set — and why even set is wrong, just more slowly.

By Shibin Das · Engineering · Published 26 May 2026. Source: https://www.d34dman.com/posts/engineering/submit-toggle-is-a-cache/
Tags: javascript, ui, state-management, frontend, engineering

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

```js
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 `toggle`s. 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 spelling[^1] 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:

```js
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:

```js
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):

```js
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:

```js
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 typed* --- `toggle()` 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.
