Home Module 15 Counter App

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

  1. Add keyboard shortcuts: pressing + or - when a counter card is focused should increment/decrement it.
  2. Add an "undo last action" button using a history stack (push state before each change, pop on undo).
  3. Display a sparkline chart (a small SVG line) showing the last 20 values for each counter.
  4. Add a "goal" field — show a progress bar from 0 to the goal value.
  5. Export all counter data as a CSV file using a Blob and URL.createObjectURL().