DEV Community

Profiterole
Profiterole

Posted on

Build Interactive Comparison Tools with Zero Dependencies

When I built the broadband and mobile plan comparison tools for Sorted MY, I made a deliberate choice: no frameworks. No React, no Vue, no build step. Just vanilla JavaScript, HTML, and CSS.

The result? Pages that load in under a second, rank well in search, and are trivially maintainable. Here is what I learned building them.

Why Vanilla JS for Comparison Tools?

Comparison tools have a specific shape: a dataset, user-controlled filters, and a rendered table or card grid. This maps cleanly onto browser primitives without needing a virtual DOM or reactive state system.

Benefits of going frameworkless:

  • Zero JS bundle overhead (no 40kB React runtime)
  • No build pipeline to maintain
  • Pages are static-friendly — ideal for GitHub Pages or any CDN
  • Search engines see fully-rendered HTML (or near-instant hydration)
  • Easier to audit and debug

The Core Pattern

Every comparison tool I build follows the same three-layer pattern:

Data layer   → plain JS array of objects
Filter layer → pure functions that return filtered arrays
Render layer → DOM manipulation that replaces innerHTML
Enter fullscreen mode Exit fullscreen mode

Here is the skeleton:

// 1. Data layer
const plans = [
  { name: "Unifi 300", speed: 300, price: 99, provider: "TM" },
  { name: "Maxis 500", speed: 500, price: 149, provider: "Maxis" },
  { name: "Time 1Gbps", speed: 1000, price: 139, provider: "Time" }
];

// 2. Filter layer
function filterPlans(data, filters) {
  return data.filter(plan => {
    if (filters.maxPrice && plan.price > filters.maxPrice) return false;
    if (filters.minSpeed && plan.speed < filters.minSpeed) return false;
    if (filters.provider && plan.provider !== filters.provider) return false;
    return true;
  });
}

// 3. Render layer
function renderTable(plans) {
  const tbody = document.querySelector("#results tbody");
  tbody.innerHTML = plans.map(plan => `
    <tr>
      <td>${plan.name}</td>
      <td>${plan.speed} Mbps</td>
      <td>RM ${plan.price}/mo</td>
      <td>${plan.provider}</td>
    </tr>
  `).join("");

  document.querySelector("#count").textContent = `${plans.length} plans found`;
}
Enter fullscreen mode Exit fullscreen mode

Wiring Up Dynamic Filtering

The key to a smooth UX is re-rendering on every input event — no submit button needed:

function getFilters() {
  return {
    maxPrice: Number(document.querySelector("#maxPrice").value) || null,
    minSpeed: Number(document.querySelector("#minSpeed").value) || null,
    provider: document.querySelector("#provider").value || null
  };
}

function update() {
  const filters = getFilters();
  const results = filterPlans(plans, filters);
  renderTable(results);
}

// Attach to all filter inputs
document.querySelectorAll(".filter-input").forEach(el => {
  el.addEventListener("input", update);
});

// Initial render
update();
Enter fullscreen mode Exit fullscreen mode

This is the entire reactive core. No state management library needed — the DOM is the state for the filter inputs, and the rendered table is always a pure function of that state.

Sorting

Add a sort control by extending the filter object:

function sortPlans(data, sortKey, sortDir) {
  if (!sortKey) return data;
  return [...data].sort((a, b) => {
    const diff = a[sortKey] - b[sortKey];
    return sortDir === "asc" ? diff : -diff;
  });
}

// In update():
const sorted = sortPlans(filtered, currentSort.key, currentSort.dir);
renderTable(sorted);
Enter fullscreen mode Exit fullscreen mode

For clickable column headers, toggle sort direction on each click:

document.querySelectorAll("th[data-sort]").forEach(th => {
  th.addEventListener("click", () => {
    const key = th.dataset.sort;
    currentSort.dir = (currentSort.key === key && currentSort.dir === "asc") ? "desc" : "asc";
    currentSort.key = key;
    update();
  });
});
Enter fullscreen mode Exit fullscreen mode

Persisting User Preferences with localStorage

Users return to comparison tools multiple times. Saving their last filter state is a small touch that makes a big difference:

const STORAGE_KEY = "broadband-filters-v1";

function saveFilters(filters) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
  } catch (e) {
    // Quota exceeded or private mode — fail silently
  }
}

function loadFilters() {
  try {
    return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
  } catch (e) {
    return {};
  }
}

// On page load, restore saved filters
const saved = loadFilters();
if (saved.maxPrice) document.querySelector("#maxPrice").value = saved.maxPrice;
if (saved.minSpeed) document.querySelector("#minSpeed").value = saved.minSpeed;

// In update(), save after each change
function update() {
  const filters = getFilters();
  saveFilters(filters);
  renderTable(filterPlans(plans, filters));
}
Enter fullscreen mode Exit fullscreen mode

The try/catch around localStorage calls is important — Safari in private mode throws on writes.

Making the Table Responsive

Wide tables break on mobile. Two approaches work well:

Approach 1 — horizontal scroll:

.table-wrapper {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}
Enter fullscreen mode Exit fullscreen mode

Approach 2 — card layout on small screens:

@media (max-width: 600px) {
  table, thead, tbody, th, td, tr {
    display: block;
  }
  td::before {
    content: attr(data-label);
    font-weight: bold;
    display: inline-block;
    width: 120px;
  }
  thead tr { display: none; }
}
Enter fullscreen mode Exit fullscreen mode

For approach 2, add data-label attributes to your <td> elements matching the column header:

`<td data-label="Speed">${plan.speed} Mbps</td>`
Enter fullscreen mode Exit fullscreen mode

Highlighting the Best Value

A simple heuristic (speed per ringgit) surfaces the best-value plan automatically:

function renderTable(plans) {
  const bestValue = plans.reduce((best, p) => {
    return (p.speed / p.price) > (best.speed / best.price) ? p : best;
  }, plans[0]);

  tbody.innerHTML = plans.map(plan => {
    const isBest = plan === bestValue;
    return `
      <tr class="${isBest ? 'best-value' : ''}">
        <td>${isBest ? '* ' : ''}${plan.name}</td>
        ...
      </tr>
    `;
  }).join("");
}
Enter fullscreen mode Exit fullscreen mode

See It in Action

These patterns power two live tools:

Both are static HTML files with no build step, hosted on GitHub Pages for free.

Performance Notes

  • Keep the data array in the JS file itself for sub-millisecond access. If the dataset grows beyond ~500 rows, consider a Web Worker for filtering.
  • innerHTML replacement on every keystroke is fast for tables under ~200 rows. For larger datasets, diff only changed rows.
  • Debounce text inputs (but not range sliders or checkboxes, which feel sluggish with debounce).
function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

document.querySelector("#search").addEventListener(
  "input",
  debounce(update, 150)
);
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

The pattern is simple: data array, filter functions, innerHTML. Everything else (sorting, localStorage, responsiveness, best-value highlighting) is a small addition on top.

The real win is what you avoid: no npm install, no webpack config, no hydration bugs, no framework upgrade treadmill. The tool you build today will still work in five years with zero maintenance.

If you are building something similar — a price comparison, a spec filter, a decision helper — start here. Add a framework only when you hit a concrete wall that vanilla JS cannot handle.

Top comments (0)