How do you decide which selectors a shared widget's JavaScript uses to find its own DOM nodes, so that a CSS or markup refactor cannot silently break its behaviour?
answer
- selectors depend on someone else's file
- styling names change for styling reasons
- give behaviour its own attribute vocabulary
- scope and anchor to the widget root
- missing hook should be loud, not silent
basics
~20 sGive behaviour its own stable hooks — dedicated data-* attributes rather than styling class names — scope every query to the widget root, avoid structural selectors, and fail loudly when a required node is missing instead of silently doing nothing.
solid answer
~50 sTreat selectors as a **contract**, not as a convenience. Styling class names change for styling reasons, so JavaScript that queries `.btn-primary` is coupled to a decision a designer is entitled to reverse. The house rule I want is: behaviour queries a dedicated attribute hook such as `[data-widget-close]`, styling queries classes, and the two never share a name. Every query runs from the widget's own root and is anchored — `root.querySelector(':scope > [data-list]')` — so page markup around the widget cannot change what it matches. Structural selectors like `div > div:nth-child(2)` are banned outright; they encode a layout accident. Resolve nodes once at initialisation, keep the references, and throw or log at that point when a required hook is missing, so the failure shows up at mount rather than as a dead button in production. The costs are markup noise and a second naming vocabulary to govern — worth it once more than one team touches the markup.
code
javascript · 25 linesclass Disclosure {
constructor(root) {
this.root = root;
// Anchored to the root, named by behaviour, not by styling.
this.trigger = root.querySelector(':scope > [data-disclosure-trigger]');
this.panel = root.querySelector(':scope > [data-disclosure-panel]');
if (!this.trigger || !this.panel) {
throw new Error('Disclosure: expected data-disclosure-trigger and data-disclosure-panel');
}
this.trigger.addEventListener('click', () => this.toggle());
}
toggle() {
const open = this.root.dataset.state === 'open';
this.root.dataset.state = open ? 'closed' : 'open';
}
}
document.body.insertAdjacentHTML('beforeend',
'<div data-state="closed">' +
'<button data-disclosure-trigger>More</button>' +
'<div data-disclosure-panel>Details</div></div>');
new Disclosure(document.querySelector('[data-disclosure-trigger]').parentElement);go deeper
Know that querySelector returns null when nothing matches, and that querying a class CSS also owns means a rename in a stylesheet can break your script.
Be ready to justify data-* hooks over class selectors, to scope queries to a component root rather than document, and to explain why nth-child selectors are fragile.
Show that you resolve required nodes once at initialisation, fail loudly when a hook is missing, and anchor queries with :scope so surrounding page markup cannot change what matches.
Own the convention across teams: which attribute namespaces exist, who may rename them, how tests bind, and where shadow DOM or direct node references make the selector contract unnecessary.
## The problem: selectors are a coupling surface Every selector string in JavaScript is an invisible dependency on markup that someone else edits. `container.querySelector('.card__footer .btn-primary')` names four things — two class conventions, a nesting relationship, and an implicit assumption about which `.btn-primary` comes first. None of them are enforced anywhere. A designer renaming `.btn-primary`, or a developer adding a wrapper `<div>` for a grid, breaks behaviour with no compile error, no type error, and often no runtime error either — `querySelector` simply returns `null` and the code takes the `?.` path into silence. The question is not "which selector is fastest". It is "which selector expresses something the markup owner has agreed not to change". ## Separate the vocabularies The durable rule is one vocabulary per consumer: - **Classes belong to CSS.** They are presentational and are expected to churn. - **`data-*` attributes belong to behaviour.** `data-menu-trigger`, `data-row-id`, `data-state="open"` — these say what a node *is* in the widget's model, not how it looks. - **Test ids belong to tests**, and it is a legitimate design choice either to reuse the behaviour hooks or to keep a separate `data-testid` namespace. Reusing them keeps markup lean and guarantees tests exercise the same anchors as the code; separating them lets you strip test ids from production builds. Pick one and write it down. - **`id` belongs to the document**, and a shared widget rendered more than once on a page cannot own one. Prefer attributes for anything a component instantiates repeatedly. A useful test of a hook: if a pure-visual redesign that changes no behaviour would require touching it, it is the wrong hook. ## Anchor and shallow-scope every query Two mechanics keep queries honest. **Scope to the widget root.** Never query from `document` inside a component; query from the root element the component was handed. Otherwise a second instance on the page, or an unrelated element with the same class, wins the race. **Anchor the selector.** An element-scoped `querySelectorAll` filters results to descendants but still matches the selector against the whole tree, so an ancestor part such as `.card` can be satisfied by an element outside the widget. Writing `:scope > [data-list]` or `:scope [data-row]` removes that ambiguity and simultaneously keeps the query shallow. ```js class Menu { constructor(root) { this.root = root; this.trigger = root.querySelector(':scope > [data-menu-trigger]'); this.panel = root.querySelector(':scope > [data-menu-panel]'); if (!this.trigger || !this.panel) { throw new Error('Menu: markup is missing data-menu-trigger or data-menu-panel'); } } } ``` ## Ban structural selectors `div > div:nth-child(2)`, `.row span:last-of-type`, `parentElement.parentElement` — all of these encode the current shape of the DOM rather than its meaning. Any wrapper added for layout, any conditional element, any localisation that reorders content shifts the match. Where you need to walk upwards, `closest('[data-row]')` is depth-independent and survives added wrappers; where you need to go down, a named hook is. ## Fail loudly, once Resolve required nodes at construction time and treat a missing hook as a programming error: throw, or log an error naming the widget and the missing hook. The anti-pattern is `this.panel?.classList.add('open')` scattered through the code, which converts a broken contract into a widget that quietly does nothing — the hardest class of frontend bug to notice, because nothing appears in the console and nothing turns red in CI. Caching resolved references also removes repeated queries from hot paths. The tradeoff is that a cached reference goes stale if the subtree is re-rendered; if the widget owns its own DOM this is fine, and if it does not, re-resolve on the mutation that replaced the subtree rather than on every interaction. ## Interpolation and escaping Any selector built from data needs `CSS.escape()`, or, better, no interpolation at all: `[data-row-id]` plus a `dataset` comparison, or `getElementById` for raw id strings, avoids the escaping question entirely. A selector assembled by string concatenation from a database value is both a throw site and, in the worst case, a place where attacker-controlled data changes what your code selects. ## Where the question dissolves If the widget owns a shadow root, its internal selectors are genuinely private — outside CSS and outside queries cannot reach in, so the coupling problem largely disappears for internals. Similarly, when a component framework hands you a direct node reference, that reference is a stronger contract than any selector and should be preferred. The discipline above is for the large middle ground: framework-free widgets, progressive enhancement layers, analytics and instrumentation code, and anything that must attach to markup another team renders.
- Should the behaviour hooks and the test selectors be the same attribute?Either is defensible, but decide once. Sharing them keeps markup lean and guarantees tests bind to the same anchors production code uses. Separating them lets a build strip `data-testid` and lets tests target things behaviour does not care about. The failure mode to avoid is drifting into both by accident, so that a hook rename fixes the code and breaks the suite.
- What is wrong with resolving nodes lazily on every interaction instead of caching them at init?It hides a broken contract: a missing hook produces `null` at click time and an optional-chained no-op, instead of an error at mount when someone can act on it. It also repeats selector matching in a hot path. Cache at construction, and re-resolve deliberately when the widget itself replaces the subtree.
- When does this whole discipline stop mattering?When the component owns a shadow root — outside selectors cannot reach its internals, so its private hooks are genuinely private — or when a framework hands the code a direct node reference instead of a selector. A direct reference is a stronger contract than any string. The discipline is for framework-free widgets and for code attaching to markup another team renders.
saying these in an interview costs you the question
- Queries styling class names from behaviour code
- Uses nth-child or parentElement chains to reach a node
- Queries from document instead of the component root
- Swallows a missing node with optional chaining
- Interpolates data values into selectors without escaping