Counter App
Multi-counter app with configurable step size, min/max limits, colour coding, and localStorage persistence.
Project Overview
A counter is the "Hello World" of state management — but we'll build a genuinely useful version with multiple independent counters, configurable step, bounds, and persistence. This practises: state objects, DOM updates from state, localStorage serialisation, and conditional styling.
What you will build
- Multiple named counters (add / remove counters)
- Increment, decrement, and reset buttons per counter
- Configurable step size (default: 1)
- Optional min / max limits — buttons disable at bounds
- Colour coding: green when positive, red when negative, grey at zero
- All counters persist in localStorage
Concepts used
- Array of state objects (one per counter)
- Unique IDs with
Date.now() - Single
render()function that rebuilds the DOM from state - Disabling buttons conditionally based on bounds
- localStorage + JSON for persistence
Key Logic
Counter state structure
// Each counter is a plain object
const defaultCounter = () => ({
id: Date.now().toString(),
name: 'Counter',
value: 0,
step: 1,
min: null, // null = no limit
max: null
});
let counters = load(); // array of counter objects
Render function
function render() {
save();
app.innerHTML = '';
counters.forEach(c => {
const card = document.createElement('div');
card.className = 'counter-card';
// Value colour class
const cls = c.value > 0 ? 'pos' : c.value < 0 ? 'neg' : 'zero';
card.innerHTML = `
<div class="c-name" contenteditable="true"
data-id="${c.id}" data-field="name">${c.name}</div>
<div class="c-value ${cls}">${c.value}</div>
<div class="c-btns">
<button data-id="${c.id}" data-action="dec"
${c.min !== null && c.value <= c.min ? 'disabled' : ''}>−</button>
<button data-id="${c.id}" data-action="reset">Reset</button>
<button data-id="${c.id}" data-action="inc"
${c.max !== null && c.value >= c.max ? 'disabled' : ''}>+</button>
</div>
`;
app.appendChild(card);
});
}
Event handling with delegation
app.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { id, action } = btn.dataset;
counters = counters.map(c => {
if (c.id !== id) return c;
if (action === 'inc') return { ...c, value: Math.min(c.value + c.step, c.max ?? Infinity) };
if (action === 'dec') return { ...c, value: Math.max(c.value - c.step, c.min ?? -Infinity) };
if (action === 'reset') return { ...c, value: 0 };
if (action === 'del') return null;
return c;
}).filter(Boolean);
render();
});
Complete Working Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Counter App</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #f1f5f9;
min-height: 100vh;
padding: 2rem 1rem;
}
.app-wrapper { max-width: 700px; margin: 0 auto; }
h1 { text-align: center; font-size: 2rem; color: #1e293b; margin-bottom: 1.5rem; }
.toolbar {
display: flex;
gap: .75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
align-items: center;
}
.toolbar label { font-size: .85rem; color: #64748b; }
.toolbar input[type=number] {
width: 70px; padding: .4rem .6rem;
border: 2px solid #e2e8f0; border-radius: .4rem;
font-size: .9rem;
}
.add-btn {
padding: .55rem 1.25rem;
background: #3b82f6; color: #fff;
border: none; border-radius: .5rem;
font-size: .9rem; cursor: pointer;
margin-left: auto;
}
.add-btn:hover { background: #2563eb; }
.counters { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
.counter-card {
background: #fff;
border-radius: .75rem;
padding: 1.5rem 1.25rem;
box-shadow: 0 1px 4px #0001;
text-align: center;
position: relative;
}
.c-del {
position: absolute; top: .6rem; right: .6rem;
background: none; border: none;
color: #cbd5e1; font-size: 1rem; cursor: pointer;
line-height: 1;
}
.c-del:hover { color: #ef4444; }
.c-name {
font-size: .9rem; font-weight: 600; color: #475569;
margin-bottom: .75rem; outline: none;
border-bottom: 1px dashed transparent;
cursor: text;
}
.c-name:focus { border-bottom-color: #3b82f6; color: #1e293b; }
.c-value {
font-size: 3rem; font-weight: 800;
line-height: 1; margin-bottom: 1rem;
transition: color .2s;
}
.c-value.pos { color: #22c55e; }
.c-value.neg { color: #ef4444; }
.c-value.zero { color: #94a3b8; }
.c-btns { display: flex; gap: .5rem; justify-content: center; }
.c-btns button {
padding: .5rem .9rem;
border: 2px solid #e2e8f0;
background: #f8fafc;
border-radius: .4rem;
font-size: 1rem;
cursor: pointer;
transition: background .1s;
}
.c-btns button:hover:not(:disabled) { background: #e2e8f0; }
.c-btns button:disabled { opacity: .35; cursor: not-allowed; }
.c-step { font-size: .72rem; color: #94a3b8; margin-top: .6rem; }
</style>
</head>
<body>
<div class="app-wrapper">
<h1>Counters</h1>
<div class="toolbar">
<label>Step <input type="number" id="step-input" value="1" min="1"></label>
<label>Min <input type="number" id="min-input" placeholder="none"></label>
<label>Max <input type="number" id="max-input" placeholder="none"></label>
<button class="add-btn" id="add-btn">+ Add Counter</button>
</div>
<div class="counters" id="app"></div>
</div>
<script>
const STORAGE_KEY = 'counters_v1';
function load() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [makeCounter()]; }
catch { return [makeCounter()]; }
}
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(counters));
}
function makeCounter(step = 1, min = null, max = null) {
return { id: Date.now().toString() + Math.random(), name: 'Counter', value: 0, step, min, max };
}
let counters = load();
const app = document.querySelector('#app');
function render() {
save();
app.innerHTML = '';
counters.forEach(c => {
const card = document.createElement('div');
card.className = 'counter-card';
const cls = c.value > 0 ? 'pos' : c.value < 0 ? 'neg' : 'zero';
const atMin = c.min !== null && c.value <= c.min;
const atMax = c.max !== null && c.value >= c.max;
const del = document.createElement('button');
del.className = 'c-del';
del.dataset.id = c.id;
del.dataset.action = 'del';
del.textContent = '✕';
del.title = 'Remove counter';
const name = document.createElement('div');
name.className = 'c-name';
name.contentEditable = 'true';
name.dataset.id = c.id;
name.dataset.field = 'name';
name.textContent = c.name;
const val = document.createElement('div');
val.className = `c-value ${cls}`;
val.textContent = c.value;
const decBtn = document.createElement('button');
decBtn.dataset.id = c.id;
decBtn.dataset.action = 'dec';
decBtn.textContent = '−';
if (atMin) decBtn.disabled = true;
const resetBtn = document.createElement('button');
resetBtn.dataset.id = c.id;
resetBtn.dataset.action = 'reset';
resetBtn.textContent = '↺';
resetBtn.title = 'Reset to 0';
const incBtn = document.createElement('button');
incBtn.dataset.id = c.id;
incBtn.dataset.action = 'inc';
incBtn.textContent = '+';
if (atMax) incBtn.disabled = true;
const btns = document.createElement('div');
btns.className = 'c-btns';
btns.append(decBtn, resetBtn, incBtn);
const stepLabel = document.createElement('div');
stepLabel.className = 'c-step';
const parts = [`step: ${c.step}`];
if (c.min !== null) parts.push(`min: ${c.min}`);
if (c.max !== null) parts.push(`max: ${c.max}`);
stepLabel.textContent = parts.join(' · ');
card.append(del, name, val, btns, stepLabel);
app.appendChild(card);
});
}
// Button actions via delegation
app.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { id, action } = btn.dataset;
counters = counters.map(c => {
if (c.id !== id) return c;
switch (action) {
case 'inc': return { ...c, value: c.max !== null ? Math.min(c.value + c.step, c.max) : c.value + c.step };
case 'dec': return { ...c, value: c.min !== null ? Math.max(c.value - c.step, c.min) : c.value - c.step };
case 'reset': return { ...c, value: 0 };
default: return c;
}
}).filter(c => !(c.id === id && action === 'del'));
render();
});
// Inline name editing
app.addEventListener('blur', e => {
const el = e.target.closest('[data-field="name"]');
if (!el) return;
const { id } = el.dataset;
const newName = el.textContent.trim() || 'Counter';
counters = counters.map(c => c.id === id ? { ...c, name: newName } : c);
save();
}, true);
// Add counter
document.querySelector('#add-btn').addEventListener('click', () => {
const step = Math.max(1, Number(document.querySelector('#step-input').value) || 1);
const minVal = document.querySelector('#min-input').value;
const maxVal = document.querySelector('#max-input').value;
const min = minVal !== '' ? Number(minVal) : null;
const max = maxVal !== '' ? Number(maxVal) : null;
counters = [...counters, makeCounter(step, min, max)];
render();
});
render();
</script>
</body>
</html>
Code Explained
State array as source of truth
All counters live in the counters array. Every action (increment, rename, delete) creates a new array and calls render(). The DOM is always a direct reflection of the data — never modified directly.
Immutable updates
counters.map(c => c.id === id ? { ...c, value: ... } : c) creates a new array with a new object for the modified counter. The spread { ...c } copies all existing fields so we only need to override the ones that changed.
Disabling at bounds
atMin and atMax are computed during render and used to set btn.disabled. Because the DOM is rebuilt from state on every action, the disabled state is always correct.
contenteditable for inline renaming
Setting contentEditable = 'true' lets the user click the counter name and type directly. A blur listener (captured with true so it works on the non-bubbling blur event) saves the new name back to state when the user clicks away.
Challenges
- Add keyboard shortcuts: pressing + or - when a counter card is focused should increment/decrement it.
- Add an "undo last action" button using a history stack (push state before each change, pop on undo).
- Display a sparkline chart (a small SVG line) showing the last 20 values for each counter.
- Add a "goal" field — show a progress bar from 0 to the goal value.
- Export all counter data as a CSV file using a Blob and
URL.createObjectURL().