JAVASCRIPT / PROJECTS AND PROFESSIONAL CRAFT
Project: an interactive counter with event listeners
Build a counter whose state lives in JavaScript, updated by one delegated click listener and keyboard steps, with a single render() function drawing it.
What you will learn
- Keep the count in a JavaScript variable and never read it back from textContent
- Handle every button with one delegated click listener plus event.target.closest
- Derive the label and each button's disabled state inside a single render() call
- Convert data-* values with Number() before arithmetic, since datasets are strings
Understanding Project: an interactive counter with event listeners
A counter is the smallest widget that still has real architecture. Keep the number itself in JavaScript, as in const state = { count: 0 }, and treat the DOM as output rather than storage. The alternative, reading Number(valueEl.textContent) at the top of every handler, means parsing your own formatting: the moment the display becomes "1,000" or "Clicked 3 times", the arithmetic turns into NaN. State in, DOM out, one direction.
Event listeners are the only thing allowed to change that state, and they should do nothing else. A click starts at the deepest element under the pointer and bubbles up through its ancestors, so one listener on the wrapping element sees clicks from every button inside it, including buttons added later. Inside that handler event.currentTarget is the wrapper you attached to, while event.target is whatever was actually hit, possibly an icon span inside the button, which is why event.target.closest('button') is the reliable way to find the control carrying the intent. The amount lives in data-step, so adding a +10 button becomes a line of markup instead of a new branch in code.
Everything visible is then derived in one place. render() writes the number, and it also sets disabled when the count hits a bound, updates aria attributes, and toggles classes, all from state and never from what the DOM currently shows. Written that way render is safe to call as often as you like, and a button can never sit enabled while the count says it should not be. Attaching to a container has a second payoff: listeners are stored on element objects, so replacing innerHTML discards every handler bound to the replaced nodes, while the container's own listener survives.
// Runs in a browser <script>. The clicks at the end are simulated so the output is fixed.
const root = document.createElement('div');
root.innerHTML =
'<output id="value">0</output>' +
'<button data-step="-1">-1</button>' +
'<button data-step="1">+1</button>' +
'<button data-reset>reset</button>';
document.body.append(root);
const state = { count: 0 };
const valueEl = root.querySelector('#value');
function render() {
valueEl.textContent = String(state.count);
console.log('rendered', valueEl.textContent);
}
root.addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return; // the gap between buttons, not a control
if (button.hasAttribute('data-reset')) {
state.count = 0;
} else {
state.count += Number(button.dataset.step);
}
render();
});
render();
root.querySelector('[data-step="1"]').click();
root.querySelector('[data-step="1"]').click();
root.querySelector('[data-step="-1"]').click();
root.querySelector('[data-reset]').click();A counter has exactly one source of truth, a number in JavaScript, and listeners only mutate that number while a single render function makes the DOM agree with it.
Worked examples
Bounds live in render, not in the handler
Shows the disabled attribute derived from state, and that a disabled button dispatches no click at all.
const state = { count: 0, max: 2 };
const inc = document.createElement('button');
inc.textContent = '+1';
document.body.append(inc);
function render() {
inc.disabled = state.count >= state.max;
console.log('count=' + state.count, 'disabled=' + inc.disabled);
}
inc.addEventListener('click', () => {
state.count += 1;
render();
});
render();
inc.click();
inc.click();
inc.click(); // ignored
Example explained
Line 1render() computes inc.disabled from state, so the button's availability cannot drift away from the count.
Line 2The second click raises count to max, and the render in the same handler disables the button immediately.
Line 3The third inc.click() logs nothing: click() returns early for disabled form controls, matching how a real user click on a disabled button is ignored.
Line 4The listener is never removed, so lowering max later needs a state change and a render, not any rewiring.
Keyboard steps from a key map
Maps key names to step sizes and cancels the default scroll only for the keys the counter claims.
const box = document.createElement('div');
box.tabIndex = 0; // a div must opt into focus; a <button> would not need this
document.body.append(box);
let count = 0;
const stepFor = { ArrowUp: 1, ArrowDown: -1, PageUp: 10, PageDown: -10 };
box.addEventListener('keydown', (event) => {
const step = stepFor[event.key];
if (step === undefined) return;
event.preventDefault();
count += step;
console.log(event.key, '->', count);
});
function press(key) {
box.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
press('ArrowUp');
press('PageUp');
press('ArrowDown');
press('a');
console.log('final', count);
Example explained
Line 1stepFor turns key names into amounts, so supporting PageUp is a data entry rather than another if branch.
Line 2if (step === undefined) return; lets Tab, letters, and shortcuts keep their normal behaviour instead of being swallowed.
Line 3preventDefault() runs only for claimed keys; without it ArrowDown and PageDown would also scroll the page while counting.
Line 4press('a') prints nothing, which proves the handler fired and declined rather than not firing.
Important notes
Real users produce these events by clicking and typing; the .click() and dispatchEvent calls exist only to make the output deterministic. Remove them and the widget behaves identically.
Prefer <button> over a clickable <div>: buttons are reachable with Tab and already fire click on Enter and Space, so no extra key handling is needed. <output> maps to role=status, a polite live region, so supporting screen readers announce the new value.
Common mistakes
Writing addEventListener('click', increment()) instead of passing increment: the function runs once during setup, addEventListener stores its undefined return value, and clicking then does nothing.
Writing state.count + button.dataset.step: dataset values are strings, so '0' + '1' is '01' and the display gains a digit per click instead of counting.
Calling addEventListener inside render() with an inline arrow function: every render registers another callback, so one click soon counts twice, then four times, then eight.
Try it yourself
Change, predict, then run
Give the counter a range of 0 to 10: clamp the new value in the click handler and derive both buttons' disabled state from state inside render(). Then add a data-step="5" button and confirm it works without editing the handler.
Open the JavaScript workspaceCheck your understanding
A counter's render() rebuilds its buttons with container.innerHTML on every update, yet the click handling was registered only once with container.addEventListener('click', ...). Why do the freshly created buttons still work?
- innerHTML clones the old buttons' listeners onto the replacement buttons.
- A click on a new button bubbles up to the container, where the single listener is still registered.
- The browser keeps listeners keyed by CSS selector, so any matching button is covered automatically.
- render() runs after the click, so the listener is re-registered in time for the next one.
Show answer
Listeners live on element objects, and a click travels from its target up through every ancestor, so the container's one listener sees clicks on any descendant no matter when it was created. Option 1 is the tempting one and is exactly backwards: innerHTML destroys the old element objects along with their listeners, which is why per-button handlers vanish after a re-render and delegation is used instead.