Give configuration and state different jobs
A reusable component needs a way to vary its behavior without requiring callers to edit its internals. An options object works well for settings chosen when the component is created, such as a counter’s starting value, increment, and label. State is different: it is the value that changes while the component runs. Keeping those roles separate makes the component easier to use and reason about.
Defaults let callers supply only the settings they need. Validation matters too: a step of zero or a text value where a number is expected could produce confusing behavior. Validate the final options after applying defaults, so the same rules cover both supplied and default values. Treat the options object as input rather than a place to store changing state; otherwise, code outside the component may accidentally alter its behavior.
Build a counter with a small public API
The following example runs in a browser page with a DOM and support for JavaScript object spread and CustomEvent. Paste it into the page’s developer console. The component uses a button supplied by the caller, renders with textContent, and exposes setValue for programmatic changes. Its constructor renders the initial value but does not announce it as a change.
class Counter { constructor(button, options = {}) { if (!(button instanceof HTMLButtonElement)) { throw new TypeError('Counter needs a button element'); } const config = { initial: 0, step: 1, label: 'Count', ...options }; if (!Number.isSafeInteger(config.initial) || !Number.isSafeInteger(config.step) || config.step <= 0 || typeof config.label !== 'string') { throw new TypeError('Invalid counter options'); } this.button = button; this.value = config.initial; this.step = config.step; this.label = config.label; this.handleClick = () => this.setValue(this.value + this.step); this.render(); this.button.addEventListener('click', this.handleClick); } render() { this.button.textContent = `${this.label}: ${this.value}`; } setValue(next) { if (!Number.isSafeInteger(next)) { throw new RangeError('Value must be a safe integer'); } if (next === this.value) return; const previousValue = this.value; this.value = next; this.render(); this.button.dispatchEvent(new CustomEvent('counter:change', { bubbles: true, detail: { value: next, previousValue } })); } destroy() { this.button.removeEventListener('click', this.handleClick); } }
The button remains a native button, so it can be activated with a keyboard as well as a pointer. The stored handleClick function gives destroy the same function reference that was registered during construction. Calling destroy removes the component’s click behavior; if the caller also added event listeners, the caller remains responsible for removing those.
Announce changes without coupling components
The component dispatches counter:change after updating its state and visible text. A caller can listen without the counter knowing what the caller will do next. The event’s detail contains the new and previous values. Its bubbles setting also lets a parent element listen for changes from counters inside it. An event dispatched on the button does not require the listener to be attached before the counter is constructed, because construction does not emit a change.
const button = document.createElement('button'); document.body.append(button); const counter = new Counter(button, { initial: 2, step: 3, label: 'Items' }); button.addEventListener('counter:change', event => { console.log(event.detail.previousValue, event.detail.value); }); button.click(); counter.setValue(11);
In this example, clicking the button changes the value from 2 to 5; setValue then changes it from 5 to 11. Calling setValue(11) again produces no event, because no state changed. dispatchEvent runs listeners synchronously, so listeners see the updated button text. Keep the event detail focused on information consumers need, rather than exposing the component’s mutable internals.
Choose event rules deliberately
A useful change event needs a clear contract: when it fires, where it fires, and what detail it carries. Here, both button activation and setValue use the same change path, while initial rendering and repeated values remain silent. Documenting those rules helps consumers avoid guessing whether an event represents an initial value or a later change. If consumers need the initial value, they can read counter.value after construction.
Test the contract in a browser by counting received events, activating the button, calling setValue with both new and repeated values, and checking the rendered label. Also try invalid options and a value outside the safe-integer range. Finally, call counter.destroy() and activate the button again to check that the component no longer responds to clicks. If a future component places its event target inside a shadow root, consider whether the event should cross that boundary; bubbling alone does not specify that behavior.



