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 — the rendering module
Section titled “ui.js — the rendering module”function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"');}
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.
Rendering the current display state
Section titled “Rendering the current display state”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.
The form submission handler
Section titled “The form submission handler”// 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.
The delete handler
Section titled “The delete handler”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.
The complete main.js so far
Section titled “The complete main.js so far”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();Exercise
Section titled “Exercise”- Implement
ui.jswithrenderExpensesandupdateTotal. - Add the form submission handler to
main.js. - Add the delete handler using event delegation.
- Test the full add and delete flow: add three expenses, delete one, confirm the list and total update.
- Add a fourth expense, refresh the page — confirm it is still there (localStorage persistence working).
ui.jsreceives data as arguments and writes to the DOM — it never reads from localStorage or the network.render()inmain.jsis the single source of truth for drawing — call it after every state change.- The
Expenseconstructor handles validation — wrapnew Expense(...)in try/catch to show user-friendly errors. - Event delegation on the list handles delete buttons that are dynamically rendered.