When you live in Malaysia, navigating financial products — broadband plans, savings accounts, credit cards, toll charges — can feel like a full-time job. Every provider has a different website, different jargon, and different fine print. I decided to fix this by building a set of interactive comparison tools using pure vanilla JavaScript.
This is what I learned from building Sorted MY, a collection of 57+ guides and comparison tools for Malaysian life admin.
Why Vanilla JS?
No React, no Vue, no build step. Just HTML, CSS, and JavaScript that any browser can run instantly. For content-heavy comparison tools, this means:
- Zero hydration delay
- No bundle size to optimise
- Easy to deploy on GitHub Pages
- Genuinely fast on mid-range Android phones (the dominant device in Malaysia)
Pattern 1: Filterable Data Tables
The core of any comparison tool is a filterable table. Here's the pattern I use everywhere:
const data = [
{ provider: "Unifi", speed: 100, price: 99, type: "fibre" },
{ provider: "Maxis", speed: 300, price: 149, type: "fibre" },
{ provider: "Celcom", speed: 50, price: 79, type: "4G" },
];
let activeFilters = { type: "all" };
function applyFilters() {
return data.filter(row => {
if (activeFilters.type !== "all" && row.type !== activeFilters.type) {
return false;
}
return true;
});
}
function renderTable(rows) {
const tbody = document.querySelector("#comparison-table tbody");
tbody.innerHTML = rows.map(row => `
<tr>
<td>${row.provider}</td>
<td>${row.speed} Mbps</td>
<td>RM ${row.price}/mo</td>
</tr>
`).join("");
}
document.querySelectorAll(".filter-btn").forEach(btn => {
btn.addEventListener("click", () => {
activeFilters.type = btn.dataset.type;
document.querySelectorAll(".filter-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
renderTable(applyFilters());
});
});
// Initial render
renderTable(data);
Key insight: keep filters as a plain object and recompute from the full dataset on every change. This is simpler than trying to show/hide rows in the DOM, and it makes adding more filter dimensions trivial.
Pattern 2: Multi-Column Sorting
Users want to sort by price, then by speed. Here's how I handle it cleanly:
let sortState = { column: "price", direction: "asc" };
function applySorting(rows) {
return [...rows].sort((a, b) => {
const valA = a[sortState.column];
const valB = b[sortState.column];
const diff = typeof valA === "string"
? valA.localeCompare(valB)
: valA - valB;
return sortState.direction === "asc" ? diff : -diff;
});
}
function toggleSort(column) {
if (sortState.column === column) {
sortState.direction = sortState.direction === "asc" ? "desc" : "asc";
} else {
sortState.column = column;
sortState.direction = "asc";
}
renderTable(applySorting(applyFilters()));
}
document.querySelectorAll("th[data-sort]").forEach(th => {
th.style.cursor = "pointer";
th.addEventListener("click", () => toggleSort(th.dataset.sort));
});
The trick is always spreading into a new array before sorting ([...rows].sort) — Array.prototype.sort mutates in place, which will mess up your filter logic if you're not careful.
Pattern 3: Responsive Tables on Mobile
This was the hardest part. Malaysian users are overwhelmingly on mobile. Wide comparison tables break badly on small screens. My solution: transform the table into stacked cards below a breakpoint.
@media (max-width: 640px) {
table, thead, tbody, th, td, tr {
display: block;
}
thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
td {
position: relative;
padding-left: 50%;
border: none;
border-bottom: 1px solid #eee;
}
td::before {
content: attr(data-label);
position: absolute;
left: 6px;
width: 45%;
font-weight: bold;
white-space: nowrap;
}
}
Then in the render function, stamp the column label as a data-label attribute:
function renderTable(rows) {
const headers = ["Provider", "Speed", "Price"];
const keys = ["provider", "speed", "price"];
const tbody = document.querySelector("#comparison-table tbody");
tbody.innerHTML = rows.map(row =>
`<tr>${keys.map((key, i) =>
`<td data-label="${headers[i]}">${row[key]}</td>`
).join("")}</tr>`
).join("");
}
Now the table reflows gracefully into labelled card rows on narrow screens.
Pattern 4: URL State for Shareability
When someone filters to "fibre plans under RM 100", they often want to share that view. Store filter state in the URL:
function pushState() {
const params = new URLSearchParams(activeFilters);
history.replaceState(null, "", `?${params}`);
}
function readState() {
const params = new URLSearchParams(location.search);
if (params.has("type")) activeFilters.type = params.get("type");
}
// Call readState() before initial render
readState();
renderTable(applySorting(applyFilters()));
This is zero-dependency deep linking. No router library needed.
What the Data Taught Me
Building these tools forced me to actually read the fine print on dozens of Malaysian financial products. A few things that surprised me:
Broadband: Quoted speeds are almost always "up to" figures for downloads. Upload speeds vary wildly — important if you work from home.
Savings accounts: Some high-yield accounts require a minimum monthly transaction count that effectively locks you into a spending pattern. The headline rate alone is misleading.
Toll charges: The difference between SmartTAG and Touch 'n Go for the same route can be significant over a year of daily commuting. Nobody talks about this.
The comparison tools at Sorted MY now cover broadband, savings accounts, credit cards, EPF withdrawal rules, toll calculators, and more. They're free, no login required, and updated manually when rates change.
Performance Wins
Since everything is static, GitHub Pages serves it from a CDN edge node. First contentful paint is typically under 1 second even on 3G. For users who hit my tools from a Google search on a slow mobile connection, this matters enormously.
The one performance trap I hit: generating large tables with innerHTML on every filter change can cause layout thrashing. The fix is to batch DOM writes:
function renderTable(rows) {
const fragment = document.createDocumentFragment();
rows.forEach(row => {
const tr = document.createElement("tr");
// ... build row
fragment.appendChild(tr);
});
const tbody = document.querySelector("tbody");
tbody.replaceChildren(fragment);
}
replaceChildren with a DocumentFragment does a single reflow instead of one per row.
Takeaways
- Vanilla JS is enough for data-heavy comparison tools — no framework needed
- Separate your data, filter, sort, and render logic — it makes adding new dimensions easy
- Test on real mid-range Android phones — what works on your MacBook will sometimes crawl on a Redmi
-
URL state is free shareability — use
URLSearchParams, it's widely supported - The CSS card trick for responsive tables is genuinely the best mobile solution I've found
If you're building tools for a specific local market, there's real value in going deep on one country's products rather than trying to be globally generic. The specificity is the feature.
All the comparison tools are live at Sorted MY — feel free to view source and steal any patterns that are useful.
Top comments (0)