Why JavaScript Should Mutate State Instead of Styles
Also available in Español The Problem A dropdown needs to open. A modal needs to appear. A nav link needs to show which section the reader is currently in. The blunt route is element.style.display = 'block' state and appearance decided in the same line, nothing left for CSS to own. Most experienced developers already avoid this. The more common pattern is a class instead: classList.toggle('is-open'). That's a real improvement, and a real state abstraction CSS decides what .is-open looks like, JavaScript just toggles whether the class is present. Here's where it gets more interesting. .is-open is drawn from the exact same namespace as every styling hook in the codebase. Nothing in the syntax marks it as state rather than appearance. A teammate six months from now, skimming the stylesheet, has no way to tell .is-open apart from .card-title except by reading the intent into the name and trusting that convention held. Why the Problem Exists Class-based toggles work, and they work by convention: the team agrees, informally, that classes prefixed is- or has- mean state, and everything else means appearance. That agreement is real, and for a disciplined team it holds for a long time. It's also exactly the shape of fragility this series has already diagnosed. Convention-based boundaries survive as long as the people who agreed to them are still around to enforce them. Nothing about a class's syntax stops it from being reused as a utility, picked up by a specificity fight the same way any other selector would be, or simply misread by someone new to the codebase who's never seen the is- convention documented anywhere. quell-light.js takes a step past convention. Its own header states the boundary plainly, before a single line of implementation: it is not a component library, it does not render anything, and its only mandate is behavioral stabilization. The interesting part isn't that it avoids inline styles most serious code already does that. It's what it reaches for instead of a state class. The First Principle State should be represented as state legible as state in the syntax itself, not just by convention. A class can do this. data-q-active does it more explicitly, because a data-* attribute has no styling meaning to compete with. It can't be confused for a utility class, can't get pulled into an unrelated specificity fight, and doesn't depend on a team-wide naming convention holding for years across every contributor who touches the file. quell-light.js's full state vocabulary: data-q-toggle, data-q-active, data-q-dismiss, data-q-spy, data-q-current describes only what's true. None of it describes appearance. CSS owns that entirely, through ordinary attribute selectors reacting to whichever of these happens to be present. Demonstrating the Principle A class-based toggle, the common and defensible baseline: trigger.addEventListener('click', () => { target.classList.toggle('is-open'); }); .is-open { display: block; } Real separation. CSS still owns appearance. But .is-open sits in the same selector space as everything else in the stylesheet, and nothing marks it as different. The attribute version, sharper by construction: trigger.addEventListener('click', () => { trigger.setAttribute('aria-expanded', 'true'); target.setAttribute('data-q-active', ''); }); [data-q-active] { display: block; } Same outcome. The difference is what the syntax itself guarantees: data-q-active can't drift into meaning something else, because it was never eligible to mean anything else. quell-light.js as the Case Study The disclosure module above is close to the actual mechanism in the file, minus exclusive-group handling and event delegation. It's the clearest proof that this discipline holds under a real accessibility requirement an accessible toggle needs both an ARIA state and a stylable hook, and the file keeps those as two explicit attributes rather than one overloaded class. The other two interactive modules hold the same line under harder conditions. Focus management enforces a strict Tab/Shift+Tab trap inside an open dialog and tracks which element to return focus to on close real state, with the dialog's appearance left entirely to CSS. The scroll watcher uses IntersectionObserver rather than a raw scroll listener to decide which section is active, expressed as a single aria-current on one nav link at a time again a state decision, rendered however the stylesheet sees fit. The Broader Lesson This sharpens article 4's boundary rather than just restating it. The claim was never that classes are wrong. It's that a boundary enforced by convention is only as durable as the team's memory of the convention, and a boundary encoded directly in the syntax doesn't have that dependency. quell-light.js didn't avoid a mistake. It picked the more explicit version of something already worth doing.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to