Skip to content

The UI Module and Rendering Layer

With the data layer working, the next step is the UI. ui.js renders the expenses list to the DOM, updates the total, and exposes an event-binding function that main.js calls. It has no knowledge of localStorage or the network — it receives data as arguments and writes to the DOM.

ui.js
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function renderExpenses(expenses) {
const list = document.querySelector('#expense-list');
const emptyState = document.querySelector('#empty-state');
if (!expenses.length) {
list.innerHTML = '';
emptyState.hidden = false;
return;
}
emptyState.hidden = true;
list.innerHTML = expenses
.map(({ id, description, category, date, amount }) => `
<li class="expense-item" data-id="${id}">
<div class="expense-main">
<span class="expense-desc">${escapeHtml(description)}</span>
<span class="expense-cat badge">${escapeHtml(category)}</span>
</div>
<div class="expense-meta">
<span class="expense-date">${formatDate(date)}</span>
<span class="expense-amount">$${amount.toFixed(2)}</span>
<button class="btn-delete" data-id="${id}" aria-label="Delete ${escapeHtml(description)}">✕</button>
</div>
</li>
`)
.join('');
}
export function updateTotal(expenses, rates = { USD: 1 }, currency = 'USD') {
const totalUSD = expenses.reduce((acc, e) => acc + e.amount, 0);
const rate = rates[currency] ?? 1;
const converted = totalUSD * rate;
document.querySelector('#total-amount').textContent =
converted.toLocaleString('en-US', { style: 'currency', currency });
}
function formatDate(dateString) {
return new Date(dateString + 'T00:00:00').toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
});
}

escapeHtml converts characters that have special meaning in HTML (<, >, &, ") into their entity equivalents before inserting user-supplied text into innerHTML. Without it, a description like <img src=x onerror=alert(1)> would be interpreted as markup rather than displayed as text — this is called a cross-site scripting (XSS) vulnerability. Any time you build HTML strings from user input, escape first.

The T00:00:00 suffix in formatDate forces the date to be parsed as local time — without it, a date like 2024-01-15 is parsed as UTC midnight and may display as January 14 in some timezones.

Add a render function to main.js that always redraws based on current state:

// main.js (additions)
let currentCategory = 'All';
let currentCurrency = 'USD';
let rates = { USD: 1 };
function render() {
const displayList = getDisplayList(currentCategory);
renderExpenses(displayList);
updateTotal(displayList, rates, currentCurrency);
}

render() is called once on init and again after every state change (add, delete, filter, currency change). Calling it redraws both the list and the total, keeping them in sync.

// main.js (additions)
document.querySelector('#expense-form').addEventListener('submit', event => {
event.preventDefault();
const form = event.target;
try {
const expense = new Expense({
description: form.description.value,
amount: parseFloat(form.amount.value),
category: form.category.value,
date: form.date.value || undefined,
});
expenses.push(expense);
saveExpenses(expenses);
render();
form.reset();
} catch (err) {
alert(err.message);
}
});

The try/catch wraps the new Expense(...) call — if validation throws (empty description, negative amount), the error message is shown to the user instead of crashing the app.

Deleting uses event delegation — one listener on the #expense-list, checking event.target.dataset.id:

document.querySelector('#expense-list').addEventListener('click', event => {
const deleteBtn = event.target.closest('.btn-delete');
if (!deleteBtn) return;
const id = deleteBtn.dataset.id;
expenses = expenses.filter(e => e.id !== id);
saveExpenses(expenses);
render();
});

closest('.btn-delete') handles the case where the click lands on a child element of the button. It walks up the DOM until it finds the button or returns null.

main.js
import Expense from './expense.js';
import { saveExpenses, loadExpenses } from './storage.js';
import { fetchRates, convertAmount } from './api.js';
import { renderExpenses, updateTotal } from './ui.js';
let expenses = loadExpenses();
let currentCategory = 'All';
let currentCurrency = 'USD';
let rates = { USD: 1 };
function filterByCategory(list, cat) { return cat === 'All' ? list : list.filter(e => e.isInCategory(cat)); }
function sortByDate(list) { return [...list].sort((a, b) => new Date(b.date) - new Date(a.date)); }
function getDisplayList() { return sortByDate(filterByCategory(expenses, currentCategory)); }
function render() {
const list = getDisplayList();
renderExpenses(list);
updateTotal(list, rates, currentCurrency);
}
document.querySelector('#expense-form').addEventListener('submit', event => {
event.preventDefault();
const form = event.target;
try {
expenses.push(new Expense({
description: form.description.value,
amount: parseFloat(form.amount.value),
category: form.category.value,
date: form.date.value || undefined,
}));
saveExpenses(expenses);
render();
form.reset();
} catch (err) {
alert(err.message);
}
});
document.querySelector('#expense-list').addEventListener('click', event => {
const btn = event.target.closest('.btn-delete');
if (!btn) return;
expenses = expenses.filter(e => e.id !== btn.dataset.id);
saveExpenses(expenses);
render();
});
render();
  1. Implement ui.js with renderExpenses and updateTotal.
  2. Add the form submission handler to main.js.
  3. Add the delete handler using event delegation.
  4. Test the full add and delete flow: add three expenses, delete one, confirm the list and total update.
  5. Add a fourth expense, refresh the page — confirm it is still there (localStorage persistence working).
  • ui.js receives data as arguments and writes to the DOM — it never reads from localStorage or the network.
  • render() in main.js is the single source of truth for drawing — call it after every state change.
  • The Expense constructor handles validation — wrap new Expense(...) in try/catch to show user-friendly errors.
  • Event delegation on the list handles delete buttons that are dynamically rendered.