Frontend

Vanilla JavaScript Patterns for Modern Browsers

A practical guide to vanilla JavaScript patterns for modern browsers covering module pattern, event delegation, state management, and component architecture without frameworks.

Vanilla JavaScript Patterns for Modern Browsers

Frameworks come and go. jQuery dominated for a decade, then Angular, then React, then Vue, then Svelte, and now whatever shipped last Tuesday. But underneath every one of them is the same language: JavaScript. And modern browsers have gotten remarkably good at supporting the patterns that made frameworks necessary in the first place.

This article covers battle-tested JavaScript patterns that work in every modern browser without a single dependency. These are patterns I reach for on projects where a framework would be overkill, where bundle size matters, or where I simply want to understand what is happening under the hood.

Prerequisites

  • Solid understanding of JavaScript fundamentals (closures, prototypes, scope)
  • Familiarity with the DOM API (querySelector, addEventListener, etc.)
  • Basic understanding of asynchronous JavaScript (callbacks, promises)
  • A modern browser (Chrome, Firefox, Safari, Edge — anything from the last 5 years)

No build tools. No transpilers. No npm. Just a text editor and a browser.

Why Vanilla JavaScript Matters

Three reasons I keep coming back to vanilla JS:

Bundle size. A typical React application ships 40-80KB of framework code before you write a single line of business logic. A vanilla JS application ships exactly what you wrote. On mobile networks in emerging markets, that difference is the gap between a usable app and a broken one.

Performance. Virtual DOM diffing is clever, but it is never faster than direct DOM manipulation when you know exactly what changed. Frameworks trade surgical precision for developer convenience. Sometimes the tradeoff is worth it. Sometimes it is not.

Understanding. Every framework abstracts the platform. When abstractions leak — and they always leak — the developer who understands the platform fixes the bug. The developer who only understands the framework opens a GitHub issue and waits.

Module Pattern and IIFE

The module pattern is the foundation of organized vanilla JavaScript. It uses closures to create private scope, exposing only what you intend to be public.

var UserModule = (function() {
  // Private state
  var users = [];
  var nextId = 1;

  // Private function
  function validateEmail(email) {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  // Public API
  return {
    add: function(name, email) {
      if (!validateEmail(email)) {
        throw new Error("Invalid email: " + email);
      }
      var user = { id: nextId++, name: name, email: email };
      users.push(user);
      return user;
    },
    getAll: function() {
      return users.slice(); // Return a copy, not the reference
    },
    findById: function(id) {
      return users.find(function(u) { return u.id === id; }) || null;
    },
    remove: function(id) {
      var index = users.findIndex(function(u) { return u.id === id; });
      if (index > -1) {
        return users.splice(index, 1)[0];
      }
      return null;
    }
  };
})();

The IIFE executes immediately, creating a closure. The users array and validateEmail function are truly private. Nothing outside this module can access them. The returned object is the only interface.

This pattern scales. You can have dozens of modules, each with clean boundaries and zero global pollution.

Event Delegation Pattern

Attaching event listeners to individual elements is the most common performance mistake in DOM-heavy applications. If you have a list of 500 items, that is 500 listeners consuming memory and slowing down rendering.

Event delegation uses a single listener on a parent element and inspects the event target to determine what was clicked.

var ListHandler = (function() {
  function init(containerSelector) {
    var container = document.querySelector(containerSelector);
    if (!container) return;

    container.addEventListener("click", function(e) {
      var target = e.target;

      // Walk up the DOM to find the element with a data-action attribute
      while (target && target !== container) {
        var action = target.getAttribute("data-action");
        if (action && actions[action]) {
          actions[action](target, e);
          return;
        }
        target = target.parentElement;
      }
    });
  }

  var actions = {
    delete: function(el, e) {
      var id = el.getAttribute("data-id");
      el.closest(".list-item").remove();
      console.log("Deleted item:", id);
    },
    edit: function(el, e) {
      var id = el.getAttribute("data-id");
      console.log("Editing item:", id);
    },
    toggle: function(el, e) {
      el.closest(".list-item").classList.toggle("completed");
    }
  };

  return { init: init };
})();

// Usage
ListHandler.init("#task-list");

This works even for elements added dynamically after the listener is attached. That is the real power: you never need to re-bind listeners when the DOM changes.

Observer / Pub-Sub Pattern

Components need to communicate without knowing about each other. The pub-sub pattern provides a central event bus that decouples producers from consumers.

var EventBus = (function() {
  var listeners = {};

  return {
    on: function(event, callback) {
      if (!listeners[event]) {
        listeners[event] = [];
      }
      listeners[event].push(callback);
      // Return an unsubscribe function
      return function() {
        listeners[event] = listeners[event].filter(function(cb) {
          return cb !== callback;
        });
      };
    },
    emit: function(event, data) {
      if (!listeners[event]) return;
      listeners[event].forEach(function(callback) {
        try {
          callback(data);
        } catch (err) {
          console.error("EventBus error on '" + event + "':", err);
        }
      });
    },
    once: function(event, callback) {
      var unsub = this.on(event, function(data) {
        unsub();
        callback(data);
      });
      return unsub;
    }
  };
})();

// Usage
var unsub = EventBus.on("user:created", function(user) {
  console.log("New user:", user.name);
});

EventBus.emit("user:created", { name: "Shane", email: "[email protected]" });
unsub(); // Clean up when done

The returned unsubscribe function prevents memory leaks. Always clean up listeners when components are destroyed.

Factory Pattern for DOM Components

Frameworks give you components. Vanilla JS gives you factory functions that create and manage DOM elements.

function createCard(options) {
  var title = options.title || "Untitled";
  var body = options.body || "";
  var onClose = options.onClose || function() {};

  var el = document.createElement("div");
  el.className = "card";
  el.innerHTML =
    '<div class="card-header">' +
      '<h3 class="card-title">' + escapeHtml(title) + '</h3>' +
      '<button class="card-close" data-action="close">&times;</button>' +
    '</div>' +
    '<div class="card-body">' + escapeHtml(body) + '</div>';

  el.querySelector(".card-close").addEventListener("click", function() {
    el.classList.add("card-removing");
    setTimeout(function() {
      el.remove();
      onClose();
    }, 300);
  });

  return {
    element: el,
    setTitle: function(newTitle) {
      el.querySelector(".card-title").textContent = newTitle;
    },
    setBody: function(newBody) {
      el.querySelector(".card-body").textContent = newBody;
    },
    destroy: function() {
      el.remove();
      onClose();
    }
  };
}

function escapeHtml(str) {
  var div = document.createElement("div");
  div.textContent = str;
  return div.innerHTML;
}

The factory returns both the DOM element and an API to manipulate it. This is the vanilla JS equivalent of a component: encapsulated state, a render output, and methods to update it.

State Management Without Frameworks

You do not need Redux. A simple state container with subscribers handles most use cases.

function createStore(initialState) {
  var state = JSON.parse(JSON.stringify(initialState));
  var subscribers = [];

  function getState() {
    return JSON.parse(JSON.stringify(state));
  }

  function setState(updater) {
    var prevState = getState();
    if (typeof updater === "function") {
      state = Object.assign({}, state, updater(state));
    } else {
      state = Object.assign({}, state, updater);
    }
    subscribers.forEach(function(fn) {
      fn(getState(), prevState);
    });
  }

  function subscribe(fn) {
    subscribers.push(fn);
    return function() {
      subscribers = subscribers.filter(function(s) { return s !== fn; });
    };
  }

  return {
    getState: getState,
    setState: setState,
    subscribe: subscribe
  };
}

// Usage
var store = createStore({ count: 0, items: [] });

store.subscribe(function(newState, prevState) {
  document.querySelector("#counter").textContent = newState.count;
});

store.setState(function(state) {
  return { count: state.count + 1 };
});

The deep copy on getState prevents external mutation. The functional updater pattern prevents stale state bugs. This is 30 lines of code doing what Redux does in thousands.

Template Literals for HTML Generation

Template literals turn string concatenation from painful to pleasant. They are the vanilla JS answer to JSX.

function renderUserList(users) {
  var html = users.map(function(user) {
    return (
      '<div class="user-card" data-id="' + user.id + '">' +
        '<img src="' + escapeAttr(user.avatar) + '" alt="' + escapeAttr(user.name) + '" loading="lazy">' +
        '<div class="user-info">' +
          '<h3>' + escapeHtml(user.name) + '</h3>' +
          '<p>' + escapeHtml(user.email) + '</p>' +
          '<span class="badge badge-' + (user.active ? 'active' : 'inactive') + '">' +
            (user.active ? 'Active' : 'Inactive') +
          '</span>' +
        '</div>' +
        '<div class="user-actions">' +
          '<button data-action="edit" data-id="' + user.id + '">Edit</button>' +
          '<button data-action="delete" data-id="' + user.id + '">Delete</button>' +
        '</div>' +
      '</div>'
    );
  }).join("");

  return html;
}

function escapeAttr(str) {
  return String(str).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

Always escape user-provided data. escapeHtml and escapeAttr are not optional — they are your defense against XSS.

Custom Events for Component Communication

The DOM has a built-in event system. Use it.

function dispatchCustom(element, eventName, detail) {
  var event = new CustomEvent(eventName, {
    bubbles: true,
    cancelable: true,
    detail: detail
  });
  element.dispatchEvent(event);
}

// A form component dispatches events
var formEl = document.querySelector("#signup-form");
formEl.addEventListener("submit", function(e) {
  e.preventDefault();
  var data = {
    name: formEl.querySelector('[name="name"]').value,
    email: formEl.querySelector('[name="email"]').value
  };
  dispatchCustom(formEl, "form:submit", data);
});

// A parent component listens
document.querySelector("#app").addEventListener("form:submit", function(e) {
  console.log("Form submitted:", e.detail);
});

Custom events bubble up the DOM just like native events. A component deep in the tree can communicate with any ancestor without direct references. This is the same pattern React calls "lifting state up," except the DOM does it natively.

Lazy Loading with IntersectionObserver

Stop loading off-screen content. IntersectionObserver tells you when elements enter the viewport.

function lazyLoad(selector) {
  var observer = new IntersectionObserver(function(entries) {
    entries.forEach(function(entry) {
      if (entry.isIntersecting) {
        var el = entry.target;
        var src = el.getAttribute("data-src");
        if (src) {
          el.src = src;
          el.removeAttribute("data-src");
        }
        el.classList.add("loaded");
        observer.unobserve(el);
      }
    });
  }, {
    rootMargin: "200px 0px",  // Start loading 200px before viewport
    threshold: 0.01
  });

  document.querySelectorAll(selector).forEach(function(el) {
    observer.observe(el);
  });

  return observer;
}

// Usage
// <img data-src="photo.jpg" alt="Lazy loaded photo">
lazyLoad("img[data-src]");

The rootMargin parameter loads images 200px before they scroll into view, so users never see a blank space. This pattern works for images, iframes, or any heavy content.

Debounce and Throttle

Two essential patterns for controlling execution frequency.

function debounce(fn, delay) {
  var timer = null;
  return function() {
    var context = this;
    var args = arguments;
    clearTimeout(timer);
    timer = setTimeout(function() {
      fn.apply(context, args);
    }, delay);
  };
}

function throttle(fn, interval) {
  var lastTime = 0;
  var timer = null;
  return function() {
    var context = this;
    var args = arguments;
    var now = Date.now();
    var remaining = interval - (now - lastTime);

    if (remaining <= 0) {
      clearTimeout(timer);
      timer = null;
      lastTime = now;
      fn.apply(context, args);
    } else if (!timer) {
      timer = setTimeout(function() {
        lastTime = Date.now();
        timer = null;
        fn.apply(context, args);
      }, remaining);
    }
  };
}

// Debounce: search input (wait for user to stop typing)
var searchInput = document.querySelector("#search");
searchInput.addEventListener("input", debounce(function(e) {
  performSearch(e.target.value);
}, 300));

// Throttle: scroll handler (run at most once every 100ms)
window.addEventListener("scroll", throttle(function() {
  updateScrollProgress();
}, 100));

Debounce waits for a pause. Throttle limits frequency. Use debounce for search inputs and form validation. Use throttle for scroll handlers, resize events, and mouse movement tracking.

Promise-Based Async Patterns

Wrap callback-based APIs in promises for cleaner async code.

function loadImage(src) {
  return new Promise(function(resolve, reject) {
    var img = new Image();
    img.onload = function() { resolve(img); };
    img.onerror = function() { reject(new Error("Failed to load: " + src)); };
    img.src = src;
  });
}

function delay(ms) {
  return new Promise(function(resolve) {
    setTimeout(resolve, ms);
  });
}

function waitForElement(selector, timeout) {
  timeout = timeout || 5000;
  return new Promise(function(resolve, reject) {
    var el = document.querySelector(selector);
    if (el) return resolve(el);

    var observer = new MutationObserver(function(mutations, obs) {
      var el = document.querySelector(selector);
      if (el) {
        obs.disconnect();
        resolve(el);
      }
    });

    observer.observe(document.body, { childList: true, subtree: true });

    setTimeout(function() {
      observer.disconnect();
      reject(new Error("Timeout waiting for: " + selector));
    }, timeout);
  });
}

// Usage
loadImage("/images/hero.jpg")
  .then(function(img) {
    document.querySelector("#hero").appendChild(img);
  })
  .catch(function(err) {
    console.error(err);
  });

The waitForElement utility is particularly useful when working with third-party scripts that inject DOM elements asynchronously.

Fetch Wrapper with Retry Logic

The native fetch API is powerful but verbose. A thin wrapper adds retry logic, timeouts, and consistent error handling.

var Http = (function() {
  var defaultOptions = {
    retries: 3,
    retryDelay: 1000,
    timeout: 10000,
    headers: { "Content-Type": "application/json" }
  };

  function request(url, options) {
    options = Object.assign({}, defaultOptions, options);
    var attempt = 0;

    function execute() {
      attempt++;
      return fetchWithTimeout(url, options)
        .then(function(response) {
          if (!response.ok) {
            throw { status: response.status, statusText: response.statusText };
          }
          var contentType = response.headers.get("content-type") || "";
          if (contentType.indexOf("application/json") > -1) {
            return response.json();
          }
          return response.text();
        })
        .catch(function(err) {
          if (attempt < options.retries && isRetryable(err)) {
            var delayMs = options.retryDelay * Math.pow(2, attempt - 1);
            return delay(delayMs).then(execute);
          }
          throw err;
        });
    }

    return execute();
  }

  function fetchWithTimeout(url, options) {
    return new Promise(function(resolve, reject) {
      var timer = setTimeout(function() {
        reject(new Error("Request timeout: " + url));
      }, options.timeout);

      fetch(url, options).then(function(response) {
        clearTimeout(timer);
        resolve(response);
      }).catch(function(err) {
        clearTimeout(timer);
        reject(err);
      });
    });
  }

  function isRetryable(err) {
    if (err.status && err.status >= 400 && err.status < 500) return false;
    return true;
  }

  return {
    get: function(url, options) {
      return request(url, Object.assign({}, options, { method: "GET" }));
    },
    post: function(url, data, options) {
      return request(url, Object.assign({}, options, {
        method: "POST",
        body: JSON.stringify(data)
      }));
    },
    put: function(url, data, options) {
      return request(url, Object.assign({}, options, {
        method: "PUT",
        body: JSON.stringify(data)
      }));
    },
    del: function(url, options) {
      return request(url, Object.assign({}, options, { method: "DELETE" }));
    }
  };
})();

// Usage
Http.get("/api/users")
  .then(function(data) { console.log(data); })
  .catch(function(err) { console.error("Request failed:", err); });

Exponential backoff (retryDelay * 2^attempt) prevents hammering a struggling server. Client errors (4xx) are not retried because they will fail every time.

Local Storage Abstraction

Raw localStorage is stringly-typed and throws on quota errors. Wrap it.

var Storage = (function() {
  var prefix = "app_";

  function isAvailable() {
    try {
      var test = "__storage_test__";
      localStorage.setItem(test, test);
      localStorage.removeItem(test);
      return true;
    } catch (e) {
      return false;
    }
  }

  var available = isAvailable();
  var fallback = {};

  return {
    get: function(key, defaultValue) {
      if (!available) return fallback[prefix + key] || defaultValue;
      try {
        var item = localStorage.getItem(prefix + key);
        if (item === null) return defaultValue;
        return JSON.parse(item);
      } catch (e) {
        return defaultValue;
      }
    },
    set: function(key, value) {
      if (!available) {
        fallback[prefix + key] = value;
        return true;
      }
      try {
        localStorage.setItem(prefix + key, JSON.stringify(value));
        return true;
      } catch (e) {
        console.warn("Storage quota exceeded for key:", key);
        return false;
      }
    },
    remove: function(key) {
      if (!available) {
        delete fallback[prefix + key];
        return;
      }
      localStorage.removeItem(prefix + key);
    },
    clear: function() {
      if (!available) {
        fallback = {};
        return;
      }
      Object.keys(localStorage).forEach(function(key) {
        if (key.indexOf(prefix) === 0) {
          localStorage.removeItem(key);
        }
      });
    }
  };
})();

The prefix prevents collisions with other scripts. The in-memory fallback keeps the app functional when localStorage is unavailable (private browsing, storage full, disabled by user). JSON serialization means you store objects, arrays, and numbers — not just strings.

Form Handling Patterns

Forms are the most common source of bugs in web applications. A reusable handler covers validation, submission, and error display.

function createFormHandler(formSelector, options) {
  var form = document.querySelector(formSelector);
  if (!form) return null;

  var validators = options.validators || {};
  var onSubmit = options.onSubmit || function() {};

  function validate() {
    var errors = {};
    var formData = getFormData();

    Object.keys(validators).forEach(function(field) {
      var value = formData[field] || "";
      var error = validators[field](value, formData);
      if (error) errors[field] = error;
    });

    return { valid: Object.keys(errors).length === 0, errors: errors };
  }

  function getFormData() {
    var data = {};
    var elements = form.elements;
    for (var i = 0; i < elements.length; i++) {
      var el = elements[i];
      if (el.name) {
        if (el.type === "checkbox") {
          data[el.name] = el.checked;
        } else if (el.type === "radio") {
          if (el.checked) data[el.name] = el.value;
        } else {
          data[el.name] = el.value.trim();
        }
      }
    }
    return data;
  }

  function showErrors(errors) {
    clearErrors();
    Object.keys(errors).forEach(function(field) {
      var input = form.querySelector('[name="' + field + '"]');
      if (input) {
        input.classList.add("is-invalid");
        var errorEl = document.createElement("div");
        errorEl.className = "field-error";
        errorEl.textContent = errors[field];
        input.parentElement.appendChild(errorEl);
      }
    });
  }

  function clearErrors() {
    form.querySelectorAll(".is-invalid").forEach(function(el) {
      el.classList.remove("is-invalid");
    });
    form.querySelectorAll(".field-error").forEach(function(el) {
      el.remove();
    });
  }

  form.addEventListener("submit", function(e) {
    e.preventDefault();
    var result = validate();
    if (!result.valid) {
      showErrors(result.errors);
      return;
    }
    clearErrors();
    onSubmit(getFormData());
  });

  return { validate: validate, getFormData: getFormData, clearErrors: clearErrors };
}

// Usage
createFormHandler("#contact-form", {
  validators: {
    name: function(value) {
      if (!value) return "Name is required";
      if (value.length < 2) return "Name must be at least 2 characters";
    },
    email: function(value) {
      if (!value) return "Email is required";
      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Invalid email address";
    }
  },
  onSubmit: function(data) {
    Http.post("/api/contact", data)
      .then(function() { alert("Message sent!"); })
      .catch(function() { alert("Send failed. Please try again."); });
  }
});

Animation with requestAnimationFrame

CSS handles most animations, but sometimes you need JavaScript control — progress bars, canvas rendering, physics-based motion.

function animate(options) {
  var duration = options.duration || 1000;
  var easing = options.easing || easeOutCubic;
  var onUpdate = options.onUpdate || function() {};
  var onComplete = options.onComplete || function() {};
  var start = null;
  var cancelled = false;

  function easeOutCubic(t) {
    return 1 - Math.pow(1 - t, 3);
  }

  function tick(timestamp) {
    if (cancelled) return;
    if (!start) start = timestamp;
    var elapsed = timestamp - start;
    var progress = Math.min(elapsed / duration, 1);
    var easedProgress = easing(progress);

    onUpdate(easedProgress);

    if (progress < 1) {
      requestAnimationFrame(tick);
    } else {
      onComplete();
    }
  }

  requestAnimationFrame(tick);

  return {
    cancel: function() { cancelled = true; }
  };
}

// Usage: slide an element from 0 to 300px
var box = document.querySelector("#animated-box");
animate({
  duration: 600,
  onUpdate: function(progress) {
    box.style.transform = "translateX(" + (progress * 300) + "px)";
  },
  onComplete: function() {
    console.log("Animation complete");
  }
});

requestAnimationFrame synchronizes with the browser's repaint cycle, giving you smooth 60fps animations. The returned cancel handle lets you abort mid-animation.

Error Boundary Pattern for Vanilla JS

React has error boundaries. Vanilla JS can do the same thing with a wrapper that catches exceptions and renders a fallback.

function withErrorBoundary(renderFn, container, fallbackHtml) {
  fallbackHtml = fallbackHtml || '<div class="error">Something went wrong. Please refresh the page.</div>';

  try {
    renderFn(container);
  } catch (err) {
    console.error("Render error:", err);
    container.innerHTML = fallbackHtml;

    // Report to error tracking service
    if (typeof reportError === "function") {
      reportError(err);
    }
  }
}

// Global handler for unhandled promise rejections
window.addEventListener("unhandledrejection", function(event) {
  console.error("Unhandled promise rejection:", event.reason);
  event.preventDefault();
});

// Global handler for uncaught errors
window.addEventListener("error", function(event) {
  console.error("Uncaught error:", event.error);
  var errorBanner = document.querySelector("#error-banner");
  if (errorBanner) {
    errorBanner.textContent = "An error occurred. Some features may not work correctly.";
    errorBanner.style.display = "block";
  }
});

// Usage
withErrorBoundary(function(container) {
  // Complex rendering logic that might throw
  var data = JSON.parse(container.getAttribute("data-config"));
  container.innerHTML = renderDashboard(data);
}, document.querySelector("#dashboard"));

The global handlers are your last line of defense. The withErrorBoundary wrapper is for specific components where you can provide meaningful fallback UI.


Complete Working Example: Vanilla JS Todo Application

Here is a complete, self-contained todo application that brings together the module pattern, event delegation, local storage, custom events, and template literals. Copy this into an HTML file and open it in a browser. No build step. No dependencies.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vanilla JS Todo</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px 20px; }
    .app { max-width: 520px; margin: 0 auto; background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); overflow: hidden; }
    .app-header { background: #2563eb; color: #fff; padding: 24px; }
    .app-header h1 { font-size: 1.5rem; margin-bottom: 16px; }
    .add-form { display: flex; gap: 8px; }
    .add-form input { flex: 1; padding: 10px 14px; border: none; border-radius: 6px; font-size: 1rem; }
    .add-form button { padding: 10px 20px; background: #1d4ed8; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 1rem; }
    .add-form button:hover { background: #1e40af; }
    .filters { display: flex; gap: 4px; padding: 12px 24px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; }
    .filters button { padding: 6px 14px; border: 1px solid #e2e8f0; background: #fff; border-radius: 4px; cursor: pointer; font-size: 0.875rem; }
    .filters button.active { background: #2563eb; color: #fff; border-color: #2563eb; }
    .todo-list { list-style: none; min-height: 60px; }
    .todo-item { display: flex; align-items: center; padding: 14px 24px; border-bottom: 1px solid #f1f5f9; transition: background 0.15s; }
    .todo-item:hover { background: #f8fafc; }
    .todo-item.completed .todo-text { text-decoration: line-through; color: #94a3b8; }
    .todo-check { width: 20px; height: 20px; margin-right: 14px; cursor: pointer; accent-color: #2563eb; }
    .todo-text { flex: 1; font-size: 1rem; }
    .todo-delete { padding: 4px 10px; background: none; border: 1px solid #fca5a5; color: #ef4444; border-radius: 4px; cursor: pointer; font-size: 0.8rem; opacity: 0; transition: opacity 0.15s; }
    .todo-item:hover .todo-delete { opacity: 1; }
    .todo-delete:hover { background: #fef2f2; }
    .app-footer { padding: 14px 24px; font-size: 0.85rem; color: #94a3b8; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #f1f5f9; }
    .clear-btn { background: none; border: none; color: #ef4444; cursor: pointer; font-size: 0.85rem; }
    .clear-btn:hover { text-decoration: underline; }
    .empty-msg { padding: 40px 24px; text-align: center; color: #94a3b8; }
  </style>
</head>
<body>
  <div class="app" id="app">
    <div class="app-header">
      <h1>Vanilla JS Todo</h1>
      <form class="add-form" id="add-form">
        <input type="text" id="todo-input" placeholder="What needs to be done?" autocomplete="off">
        <button type="submit">Add</button>
      </form>
    </div>
    <div class="filters" id="filters">
      <button data-filter="all" class="active">All</button>
      <button data-filter="active">Active</button>
      <button data-filter="completed">Completed</button>
    </div>
    <ul class="todo-list" id="todo-list"></ul>
    <div class="app-footer" id="footer" style="display:none;">
      <span id="count"></span>
      <button class="clear-btn" id="clear-completed">Clear completed</button>
    </div>
  </div>

  <script>
    // ---- Storage Module ----
    var TodoStorage = (function() {
      var KEY = "vanilla_todos";

      return {
        load: function() {
          try {
            var data = localStorage.getItem(KEY);
            return data ? JSON.parse(data) : [];
          } catch (e) {
            return [];
          }
        },
        save: function(todos) {
          try {
            localStorage.setItem(KEY, JSON.stringify(todos));
          } catch (e) {
            console.warn("Could not save todos to localStorage.");
          }
        }
      };
    })();

    // ---- Event Bus ----
    var Bus = (function() {
      var listeners = {};
      return {
        on: function(event, fn) {
          if (!listeners[event]) listeners[event] = [];
          listeners[event].push(fn);
        },
        emit: function(event, data) {
          (listeners[event] || []).forEach(function(fn) { fn(data); });
        }
      };
    })();

    // ---- Store ----
    var Store = (function() {
      var state = {
        todos: TodoStorage.load(),
        filter: "all",
        nextId: Date.now()
      };

      function persist() {
        TodoStorage.save(state.todos);
      }

      return {
        getState: function() { return state; },
        addTodo: function(text) {
          state.todos.push({ id: state.nextId++, text: text, completed: false });
          persist();
          Bus.emit("state:changed");
        },
        toggleTodo: function(id) {
          state.todos.forEach(function(t) {
            if (t.id === id) t.completed = !t.completed;
          });
          persist();
          Bus.emit("state:changed");
        },
        removeTodo: function(id) {
          state.todos = state.todos.filter(function(t) { return t.id !== id; });
          persist();
          Bus.emit("state:changed");
        },
        clearCompleted: function() {
          state.todos = state.todos.filter(function(t) { return !t.completed; });
          persist();
          Bus.emit("state:changed");
        },
        setFilter: function(filter) {
          state.filter = filter;
          Bus.emit("state:changed");
        }
      };
    })();

    // ---- Renderer ----
    var Renderer = (function() {
      var listEl = document.querySelector("#todo-list");
      var countEl = document.querySelector("#count");
      var footerEl = document.querySelector("#footer");
      var filterBtns = document.querySelectorAll("#filters button");

      function escapeHtml(str) {
        var div = document.createElement("div");
        div.textContent = str;
        return div.innerHTML;
      }

      function getVisibleTodos(state) {
        if (state.filter === "active") {
          return state.todos.filter(function(t) { return !t.completed; });
        }
        if (state.filter === "completed") {
          return state.todos.filter(function(t) { return t.completed; });
        }
        return state.todos;
      }

      function render() {
        var state = Store.getState();
        var visible = getVisibleTodos(state);
        var activeCount = state.todos.filter(function(t) { return !t.completed; }).length;
        var completedCount = state.todos.length - activeCount;

        if (visible.length === 0) {
          listEl.innerHTML = '<li class="empty-msg">No tasks here.</li>';
        } else {
          listEl.innerHTML = visible.map(function(todo) {
            return (
              '<li class="todo-item' + (todo.completed ? ' completed' : '') + '" data-id="' + todo.id + '">' +
                '<input type="checkbox" class="todo-check" data-action="toggle" data-id="' + todo.id + '"' + (todo.completed ? ' checked' : '') + '>' +
                '<span class="todo-text">' + escapeHtml(todo.text) + '</span>' +
                '<button class="todo-delete" data-action="delete" data-id="' + todo.id + '">Delete</button>' +
              '</li>'
            );
          }).join("");
        }

        countEl.textContent = activeCount + " item" + (activeCount !== 1 ? "s" : "") + " left";
        footerEl.style.display = state.todos.length > 0 ? "flex" : "none";
        document.querySelector("#clear-completed").style.display = completedCount > 0 ? "inline" : "none";

        filterBtns.forEach(function(btn) {
          btn.classList.toggle("active", btn.getAttribute("data-filter") === state.filter);
        });
      }

      return { render: render };
    })();

    // ---- Event Handlers (Delegation) ----
    (function() {
      // Add todo
      document.querySelector("#add-form").addEventListener("submit", function(e) {
        e.preventDefault();
        var input = document.querySelector("#todo-input");
        var text = input.value.trim();
        if (text) {
          Store.addTodo(text);
          input.value = "";
          input.focus();
        }
      });

      // List actions via delegation
      document.querySelector("#todo-list").addEventListener("click", function(e) {
        var target = e.target;
        var action = target.getAttribute("data-action");
        var id = parseInt(target.getAttribute("data-id"), 10);

        if (action === "toggle") {
          Store.toggleTodo(id);
        } else if (action === "delete") {
          Store.removeTodo(id);
        }
      });

      // Filter buttons via delegation
      document.querySelector("#filters").addEventListener("click", function(e) {
        var filter = e.target.getAttribute("data-filter");
        if (filter) {
          Store.setFilter(filter);
        }
      });

      // Clear completed
      document.querySelector("#clear-completed").addEventListener("click", function() {
        Store.clearCompleted();
      });

      // Listen for state changes and re-render
      Bus.on("state:changed", Renderer.render);

      // Initial render
      Renderer.render();
    })();
  </script>
</body>
</html>

This is a fully functional application in a single file. It persists data across page refreshes. It filters by status. It uses event delegation for all list interactions. The store is the single source of truth, and the renderer is a pure function of state. No framework needed.

Common Issues and Troubleshooting

Memory leaks from event listeners. Every addEventListener without a corresponding removeEventListener is a potential leak. When you remove elements from the DOM, their listeners persist if other code holds a reference. Use event delegation to minimize the number of listeners, and always clean up in a destroy method.

Stale closures capturing old state. A closure captures the variable binding, not the value. If you read state inside a setTimeout callback, you get the state at execution time, not at the time you called setTimeout. Use the functional updater pattern (setState(function(current) { ... })) to always read current state.

innerHTML and XSS vulnerabilities. Setting innerHTML with user-provided data is the number one XSS vector. Always escape HTML entities before inserting. Better yet, use textContent for plain text and only use innerHTML with data you control or have explicitly sanitized.

localStorage quota exceeded in Safari. Safari's private browsing mode throws a QuotaExceededError on any setItem call, even with zero data stored. Always wrap localStorage calls in try/catch. The storage abstraction pattern shown above handles this gracefully.

Race conditions with rapid state updates. If multiple async operations update state independently, they can overwrite each other. Serialize state updates through a single setState function and use the functional updater pattern to base changes on current state rather than stale captures.

Best Practices

  1. Use event delegation by default. Attach listeners to containers, not individual elements. This handles dynamic content, reduces memory usage, and simplifies cleanup.

  2. Always escape user input. Whether rendering with innerHTML or building attribute strings, escape everything that comes from outside your code. XSS is the easiest vulnerability to introduce and the easiest to prevent.

  3. Return unsubscribe functions. Any subscribe, on, or addEventListener call should return a cleanup function. This prevents memory leaks and makes component lifecycle management explicit.

  4. Separate state from the DOM. Keep your data in JavaScript objects. Render the DOM from that data. Never read the DOM to determine application state. This makes your code predictable and testable.

  5. Use progressive enhancement. Start with functional HTML. Layer on JavaScript behavior. If JS fails to load or throws an error, the page should still be usable. This is not always possible, but it should be the default approach.

  6. Batch DOM updates. Reading layout properties (offsetHeight, getBoundingClientRect) forces the browser to recalculate styles. Group all DOM reads together, then group all DOM writes together. Interleaving reads and writes causes layout thrashing.

  7. Prefix localStorage keys. Use a namespace prefix on all storage keys to avoid collisions with other scripts, analytics tools, or browser extensions that share the same origin.

References

Powered by Contentful