JavaScript Counter: Build One Step by Step
A click counter looks like one number and three buttons, but it contains the same moving parts as a larger interface: an event happens, state changes, and the page renders that state.
A JavaScript counter stores one numeric value as state, changes it through Increase, Decrease, and Reset actions, then renders and saves the validated result.
JavaScript Counter: Build One Step by Step
What the counter will build
The counter has one authoritative value: a JavaScript number named count. The text displayed in the page is a view of that number, not a second place to store it.
Three actions can change the state:
increaseadds1.decreasesubtracts1.resetrestores the starting value.
Every action follows the same flow. A button emits a click event, the event listener chooses the next state, and one render() function copies that state into the document. The displayed text never becomes input for the next calculation.
That separation matters. If one handler changes count while another edits the displayed text directly, the two values can disagree. The page might show 4 while JavaScript still holds 3, and the next click exposes the split.
The finished counter also has an inclusive range from 0 to 10. At either boundary, the unavailable button becomes disabled. The value is stored in localStorage, restored defensively, converted from text to a number, and checked before the first render.
This is a small project on purpose. It puts the event, state, render, and storage cycle into one file where every step remains visible. The JavaScript projects for practice collection applies the same habit to larger exercises.
Create the HTML and CSS
Start with the counter’s visible structure:
<main class="counter" aria-labelledby="counter-title">
<h1 id="counter-title">Guest counter</h1>
<p class="counter-status" aria-live="polite" aria-atomic="true">
Current count:
<span id="counter-value">0</span>
</p>
<div class="counter-controls">
<button type="button" data-action="decrease">Decrease</button>
<button type="button" data-action="reset">Reset</button>
<button type="button" data-action="increase">Increase</button>
</div>
</main>
The visible text gives each button an accessible name without an extra aria-label. Native buttons also provide the expected keyboard behavior, so the same controls work without replacing them with clickable div elements.
Each button has type="button". A button associated with a form defaults to submission when its type is omitted, which can reload or navigate away from the page. An explicit type makes the intended behavior clear even if this counter later moves inside a form.
The data-action attributes describe what each button does. JavaScript will read those values and send all three controls through one listener rather than maintaining three similar functions.
Now give the counter a responsive layout and visible interaction states:
:root {
font-family: system-ui, sans-serif;
color: #172033;
background: #f4f6fb;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 1rem;
}
.counter {
width: min(100%, 32rem);
padding: clamp(1.25rem, 5vw, 2.5rem);
border: 1px solid #c8cede;
border-radius: 1rem;
background: #ffffff;
text-align: center;
}
.counter h1 {
margin-top: 0;
}
.counter-status {
margin: 2rem 0;
font-size: 1.25rem;
}
#counter-value {
display: block;
margin-top: 0.25rem;
font-size: clamp(3rem, 15vw, 6rem);
font-weight: 700;
line-height: 1;
}
.counter-controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
}
button {
min-width: 7rem;
padding: 0.75rem 1rem;
border: 2px solid #3157d5;
border-radius: 0.5rem;
color: #ffffff;
background: #3157d5;
font: inherit;
cursor: pointer;
}
button:hover:not(:disabled) {
background: #2344af;
}
button:focus-visible {
outline: 3px solid #a15c00;
outline-offset: 3px;
}
button:disabled {
border-color: #8991a5;
color: #4f5668;
background: #d9dce5;
cursor: not-allowed;
}
The controls wrap when the available width becomes narrow. Disabled buttons have a distinct color and cursor, while :focus-visible makes keyboard focus easy to locate. Styles and classes covers the DOM side of changing visual states from JavaScript.
Connect events, state, and rendering
State is the information the interface needs to remember. This counter needs one piece of it:
let count = 0;
count is a number, so count + 1 performs arithmetic. The displayed 0 in the HTML is only initial content shown before JavaScript renders.
Next, select the value and the three buttons:
const valueElement = document.querySelector('#counter-value');
const buttons = document.querySelectorAll('[data-action]');
document.querySelector() returns the first element matching its CSS selector, or null when nothing matches. querySelectorAll() returns the matching controls here, which lets one loop register the same listener on each button.
The render function has one job: copy the current state into the DOM.
function render() {
valueElement.textContent = String(count);
}
Setting textContent replaces the element’s children with text containing the supplied value. The counter displays text rather than HTML, so there is no reason to parse markup.
JavaScript state is the source of truth. Change count first, then call render(). Do not read valueElement.textContent back and treat the page as storage.
The three actions can share one update function:
function updateCounter(action) {
if (action === 'increase') {
count += 1;
}
if (action === 'decrease') {
count -= 1;
}
if (action === 'reset') {
count = 0;
}
render();
}
Every branch changes the same variable and reaches the same render step. The function does not need to know which button was clicked, only which action was requested.
Register the click listeners from the data-action values:
buttons.forEach((button) => {
button.addEventListener('click', () => {
updateCounter(button.dataset.action);
});
});
render();
addEventListener() registers a function for the named event. It keeps the behavior in JavaScript instead of placing onclick expressions in the HTML, and it also permits other listeners to be registered on the same control later.
The last render() initializes the view from state. This call looks redundant while both places contain 0, but it establishes the rule that the page receives its value from JavaScript. When stored data is added later, the same call displays the restored value.
The full flow now reads in one direction: the clicked button supplies an action, updateCounter() changes count, and render() writes count to the page.
Done? Not quite. Repeated clicks can still move the value outside the range the interface is meant to accept.
Add minimum and maximum limits
An inclusive range allows both endpoint values. With a minimum of 0 and a maximum of 10, every integer from 0 through 10 is valid, while -1 and 11 are not.
Replace the earlier let count = 0; declaration with these rules and the named starting value:
const MIN_COUNT = 0;
const MAX_COUNT = 10;
const INITIAL_COUNT = 0;
let count = INITIAL_COUNT;
The names make the range part of the counter’s configuration rather than a collection of unexplained numbers scattered through its handlers. The arithmetic itself follows the basic operators and maths rules.
A clamp forces a value into that range:
function clamp(value) {
return Math.min(MAX_COUNT, Math.max(MIN_COUNT, value));
}
If value is -1, Math.max() raises it to 0. If it is 11, Math.min() lowers it to 10. A value already inside the range passes through unchanged.
Apply the clamp to every requested change:
function updateCounter(action) {
let nextCount = count;
if (action === 'increase') {
nextCount += 1;
}
if (action === 'decrease') {
nextCount -= 1;
}
if (action === 'reset') {
nextCount = INITIAL_COUNT;
}
count = clamp(nextCount);
render();
}
The function calculates a candidate first, then validates it before assigning the authoritative state. Even if updateCounter('decrease') runs while the value is already 0, count remains valid.
Now make the controls reflect the same rule. Select the boundary buttons and expand render():
const decreaseButton = document.querySelector('[data-action="decrease"]');
const increaseButton = document.querySelector('[data-action="increase"]');
function render() {
valueElement.textContent = String(count);
decreaseButton.disabled = count <= MIN_COUNT;
increaseButton.disabled = count >= MAX_COUNT;
}
At 0, Decrease is disabled. At 10, Increase is disabled. The clamp protects the data, and the disabled states communicate which actions are available.
The two layers work together. A disabled button prevents an ordinary pointer or keyboard activation, while clamping protects calls made elsewhere in the program.
Save the counter with localStorage
localStorage belongs to the document’s origin, and its data normally remains available after the browser closes and reopens. It stores strings, so saving a number and restoring a number are two different operations.
Use one stable key for this counter:
const STORAGE_KEY = 'javascript-counter-value';
Saving is short, but storage access can fail in restricted environments. Keep the working counter independent from persistence:
function saveCount() {
try {
localStorage.setItem(STORAGE_KEY, String(count));
} catch {
// the counter still works without persistence
}
}
String(count) makes the conversion explicit. If the write fails, the catch block leaves the in-memory state and rendered page alone.
Loading needs more care. getItem() returns either the stored string or null, and neither result should be used for arithmetic without validation:
function loadCount() {
try {
const storedValue = localStorage.getItem(STORAGE_KEY);
if (storedValue === null) {
return INITIAL_COUNT;
}
const parsedValue = Number(storedValue);
if (!Number.isFinite(parsedValue)) {
return INITIAL_COUNT;
}
return clamp(parsedValue);
} catch {
return INITIAL_COUNT;
}
}
A missing key restores 0. Text such as "not-a-number" becomes NaN and fails Number.isFinite(). A finite value outside the range is clamped to the same boundaries used for clicks, so stored "200" becomes 10 rather than bypassing the counter’s state rule.
After validating and assigning the next state, render it before saving it:
function updateCounter(action) {
let nextCount = count;
if (action === 'increase') {
nextCount += 1;
}
if (action === 'decrease') {
nextCount -= 1;
}
if (action === 'reset') {
nextCount = INITIAL_COUNT;
}
count = clamp(nextCount);
render();
saveCount();
}
The complete flow is event → state → render → storage: a click supplies the action, the validated result becomes count, render() updates the page, and saveCount() persists the rendered state.
Replace the earlier let count = INITIAL_COUNT; declaration so initialization loads first and renders once:
let count = loadCount();
render();
Do not initialize with let count = localStorage.getItem(STORAGE_KEY). A retrieved value such as "5" is a string, and "5" + 1 produces "51". The Numbers guide takes the numeric conversions further.
Make the counter accessible
The counter starts with native buttons, which already respond to expected pointer and keyboard activation. The visible focus outline shows which control is active, and disabled buttons expose unavailable actions through the button’s own disabled state.
The status paragraph has aria-live="polite". When render() changes the number, this asks assistive technologies to announce the update at the next graceful opportunity without normally interrupting the current task.
Keep the announcement concise. With aria-atomic="true", the surrounding text gives the changing number context: Current count: 4.
Debug common counter errors
When the counter does not behave as expected, check these failures in order:
- A selector returns
null. Confirm that the selector matches the HTML and that the script runs after the counter markup has been parsed. - A click submits a form. Confirm that every counter control has
type="button". - Incrementing produces
51after5. Convert restored data withNumber()and requireNumber.isFinite()before assigning it tocount. - The display is one click behind. Change state before calling
render(), and callrender()after every accepted action. - The displayed number and next calculation disagree. Stop changing
textContentinside individual event handlers. Changecount, then let the shared render function update the DOM. - A stored value breaks the range. Pass restored finite numbers through the same
clamp()function used by click actions. - Clicks work but refresh persistence does not. Check the browser’s storage and privacy settings and confirm that loading and saving use the same key. The sample intentionally suppresses thrown storage exceptions so the counter still works without persistence.
The browser’s developer console helps inspect count, test selectors, and replace stored values while debugging. For example, storing invalid text and reloading should restore the initial count instead of displaying NaN.
Complete JavaScript counter
The final version keeps the markup, styles, and script in one runnable document:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JavaScript counter</title>
<style>
:root {
font-family: system-ui, sans-serif;
color: #172033;
background: #f4f6fb;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 1rem;
}
.counter {
width: min(100%, 32rem);
padding: clamp(1.25rem, 5vw, 2.5rem);
border: 1px solid #c8cede;
border-radius: 1rem;
background: #ffffff;
text-align: center;
}
.counter h1 {
margin-top: 0;
}
.counter-status {
margin: 2rem 0;
font-size: 1.25rem;
}
#counter-value {
display: block;
margin-top: 0.25rem;
font-size: clamp(3rem, 15vw, 6rem);
font-weight: 700;
line-height: 1;
}
.counter-controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
}
button {
min-width: 7rem;
padding: 0.75rem 1rem;
border: 2px solid #3157d5;
border-radius: 0.5rem;
color: #ffffff;
background: #3157d5;
font: inherit;
cursor: pointer;
}
button:hover:not(:disabled) {
background: #2344af;
}
button:focus-visible {
outline: 3px solid #a15c00;
outline-offset: 3px;
}
button:disabled {
border-color: #8991a5;
color: #4f5668;
background: #d9dce5;
cursor: not-allowed;
}
</style>
</head>
<body>
<main class="counter" aria-labelledby="counter-title">
<h1 id="counter-title">Guest counter</h1>
<p class="counter-status" aria-live="polite" aria-atomic="true">
Current count:
<span id="counter-value">0</span>
</p>
<div class="counter-controls">
<button type="button" data-action="decrease">Decrease</button>
<button type="button" data-action="reset">Reset</button>
<button type="button" data-action="increase">Increase</button>
</div>
</main>
<script>
const MIN_COUNT = 0;
const MAX_COUNT = 10;
const INITIAL_COUNT = 0;
const STORAGE_KEY = 'javascript-counter-value';
const valueElement = document.querySelector('#counter-value');
const buttons = document.querySelectorAll('[data-action]');
const decreaseButton = document.querySelector(
'[data-action="decrease"]'
);
const increaseButton = document.querySelector(
'[data-action="increase"]'
);
function clamp(value) {
return Math.min(MAX_COUNT, Math.max(MIN_COUNT, value));
}
function loadCount() {
try {
const storedValue = localStorage.getItem(STORAGE_KEY);
if (storedValue === null) {
return INITIAL_COUNT;
}
const parsedValue = Number(storedValue);
if (!Number.isFinite(parsedValue)) {
return INITIAL_COUNT;
}
return clamp(parsedValue);
} catch {
return INITIAL_COUNT;
}
}
function saveCount() {
try {
localStorage.setItem(STORAGE_KEY, String(count));
} catch {
// the counter still works without persistence
}
}
function render() {
valueElement.textContent = String(count);
decreaseButton.disabled = count <= MIN_COUNT;
increaseButton.disabled = count >= MAX_COUNT;
}
function updateCounter(action) {
let nextCount = count;
if (action === 'increase') {
nextCount += 1;
}
if (action === 'decrease') {
nextCount -= 1;
}
if (action === 'reset') {
nextCount = INITIAL_COUNT;
}
count = clamp(nextCount);
render();
saveCount();
}
let count = loadCount();
buttons.forEach((button) => {
button.addEventListener('click', () => {
updateCounter(button.dataset.action);
});
});
render();
</script>
</body>
</html>
Save the document as index.html and open it in a browser. Increase stops at 10, Decrease stops at 0, Reset returns to 0, and the current value survives a refresh when storage is available.
Test the same actions with the keyboard by moving focus with Tab and activating a button. Then place invalid text under the javascript-counter-value storage key and reload. The counter rejects it and renders 0.
The code stays small because each concern has one place: events request changes, count holds state, clamp() protects the range, render() updates the interface, and the storage functions handle persistence. Coding style develops that separation further, while JavaScript Fundamentals carries the same ideas through functions, objects, browser APIs, and larger programs.