JavaScript Tutorial
JavaScript Project: Expense Tracker
Add amounts with a label, list them, compute the total, and keep the list in localStorage.
What you will build
An expense list. Each row has a label and an amount. The page totals every amount with reduce. Reload the preview and the rows are still there: the array is saved as JSON in localStorage.
This is the to-do pattern plus money and persistence. The DOM is a redraw. Delete filters by id. The total is never typed by hand.
Open an example with Try it in JavaScript. That loads /javascript/try — a live page and a console.
Methods you will use
| Method | Job on this page |
|---|---|
| array | Holds each expense as { id, label, amount } |
reduce | Sums every amount into one total |
localStorage | Saves the JSON list so a reload does not wipe it |
JSON.parse / JSON.stringify | Turns the array into a string and back |
Number + toFixed | Validates the amount and formats the total |
Save after every change that mutates the array: add and delete. Render after save. If you render first and forget to save, the next reload shows the old list.
Build in slices
Two fields, an add button, a list, and a total line. Labels on both inputs.
Example
<label for="label">Label</label>
<input id="label" type="text" placeholder="Bus ticket">
<label for="amount">Amount</label>
<input id="amount" type="number" min="0" step="0.01">
<button id="add" type="button">Add expense</button>
<ul id="list"></ul>
<p id="total">Total: $0.00</p>Load JSON carefully. Bad storage should become an empty array, not a thrown error that kills the page.reduce starts at 0 so an empty list totals to zero.
Example
const KEY = "expenses";
function loadItems() {
try {
const raw = localStorage.getItem(KEY);
const data = raw ? JSON.parse(raw) : [];
if (!Array.isArray(data)) return [];
return data;
} catch (err) {
return [];
}
}
function totalOf(items) {
return items.reduce(function (sum, item) {
return sum + item.amount;
}, 0);
}
function saveItems(items) {
localStorage.setItem(KEY, JSON.stringify(items));
}Add only when the label is non-empty and the amount is a finite number greater than zero. Storeamount as a number, not as a formatted string, so reduce can add it.
Example
<p id="total"></p>
<script>
const items = [
{ id: 1, label: "Bus ticket", amount: 2.75 },
{ id: 2, label: "Notebook", amount: 4.20 }
];
function totalOf(rows) {
return rows.reduce(function (sum, item) {
return sum + item.amount;
}, 0);
}
document.getElementById("total").textContent = "Total: $" + totalOf(items).toFixed(2);
</script>Add two rows, reload /javascript/try, and confirm both remain. Then delete one. The total must drop. Use Try it in JavaScript.
Complete document
This file is the tracker you would keep. Delete is per row. The total uses reduce on every render. Storage key is expenses.
Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Expense Tracker</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #fefce8;
font-family: "Segoe UI", system-ui, sans-serif;
color: #1c1917;
}
.card {
width: min(26rem, 92vw);
background: #fffbeb;
border: 1px solid #fde68a;
padding: 1.4rem 1.5rem 1.5rem;
}
h1 { margin: 0 0 0.85rem; font-size: 1.4rem; }
label { display: block; font-weight: 700; margin: 0.45rem 0 0.3rem; }
input {
width: 100%;
box-sizing: border-box;
padding: 0.45rem 0.55rem;
border: 1px solid #d6d3d1;
font: inherit;
}
button {
margin-top: 0.75rem;
padding: 0.5rem 0.85rem;
border: 0;
background: #854d0e;
color: #fffbeb;
font: inherit;
font-weight: 700;
cursor: pointer;
}
#note { margin: 0.55rem 0 0; min-height: 1.2rem; color: #78716c; }
ul { list-style: none; margin: 1rem 0 0; padding: 0; }
li {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.45rem 0;
border-top: 1px solid #fde68a;
}
li span { flex: 1; }
li strong { font-variant-numeric: tabular-nums; }
li button {
margin: 0;
background: #d6d3d1;
color: #1c1917;
font-weight: 600;
}
#total {
margin: 0.85rem 0 0;
padding-top: 0.75rem;
border-top: 2px solid #fde68a;
font-weight: 800;
}
</style>
</head>
<body>
<article class="card">
<h1>Expense Tracker</h1>
<label for="label">Label</label>
<input id="label" type="text" placeholder="Bus ticket">
<label for="amount">Amount</label>
<input id="amount" type="number" min="0" step="0.01" placeholder="2.75">
<button id="add" type="button">Add expense</button>
<p id="note"></p>
<ul id="list"></ul>
<p id="total"></p>
</article>
<script>
const KEY = "expenses";
const labelField = document.getElementById("label");
const amountField = document.getElementById("amount");
const list = document.getElementById("list");
const totalEl = document.getElementById("total");
const note = document.getElementById("note");
function loadItems() {
try {
const raw = localStorage.getItem(KEY);
const data = raw ? JSON.parse(raw) : [];
return Array.isArray(data) ? data : [];
} catch (err) {
return [];
}
}
let items = loadItems();
let nextId = items.reduce(function (max, item) {
return item.id > max ? item.id : max;
}, 0) + 1;
function money(n) {
return "$" + n.toFixed(2);
}
function totalOf(rows) {
return rows.reduce(function (sum, item) {
return sum + Number(item.amount);
}, 0);
}
function save() {
localStorage.setItem(KEY, JSON.stringify(items));
}
function render() {
list.replaceChildren();
items.forEach(function (item) {
const row = document.createElement("li");
const name = document.createElement("span");
name.textContent = item.label;
const cost = document.createElement("strong");
cost.textContent = money(Number(item.amount));
const del = document.createElement("button");
del.type = "button";
del.textContent = "Delete";
del.addEventListener("click", function () {
items = items.filter(function (rowItem) {
return rowItem.id !== item.id;
});
save();
render();
});
row.append(name, cost, del);
list.appendChild(row);
});
totalEl.textContent = "Total: " + money(totalOf(items));
}
document.getElementById("add").addEventListener("click", function () {
const label = labelField.value.trim();
const amount = Number(amountField.value);
if (!label) {
note.textContent = "Enter a label.";
return;
}
if (!isFinite(amount) || amount <= 0) {
note.textContent = "Enter an amount greater than 0.";
return;
}
items.push({ id: nextId, label: label, amount: amount });
nextId += 1;
labelField.value = "";
amountField.value = "";
note.textContent = "";
save();
render();
labelField.focus();
});
render();
</script>
</body>
</html>JSON in localStorage
JSON.stringify will drop functions and turn NaN into null. That is why the amount is validated before it enters the array. On the way back, Number(item.amount)in reduce protects against an old string that snuck into storage.
Date.now() can be an id if you add at human speed. Two adds in the same millisecond would collide. The complete document uses an incrementing nextId seeded from the largest stored id so reloads do not reuse 1.
Common mistakes
- Saving the formatted string
"$2.75".reducewould concatenate or yieldNaN. - Calling
JSON.parseonnullwithout a guard. Missing storage is not invalid JSON, but a leftover""is. - Totalling in a running variable and forgetting to subtract on delete. Recalculate with
reduceevery render. - Using
innerHTMLto print the label. Treat labels as text. - Storing the list and never calling
saveafter delete. Reload would resurrect the row.
Practice tasks
- Add a Clear all button that sets the array to
[], saves, and rerenders. - Show a count of rows next to the total, using
items.length. - Ignore amounts with more than two decimal places, or round them with
Math.round(amount * 100) / 100. Preview at /javascript/try.