Project: Expense Tracker
Table of Contents + −
In the previous lesson, we built a quiz app that scored answers and showed results. Now let’s build an expense tracker that records income and expenses, shows a running balance, and remembers your data after you reload the page.
🎯 What We’ll Build
An expense tracker lets you log money coming in and money going out, then see where you stand. Each entry has a description and an amount. Positive amounts are income, negative amounts are expenses, and the balance is everything added together.
This project ties together everything you have learned so far:
- An array of transaction objects holds the data.
- A form with
preventDefaultadds a new transaction. forEachrenders the list on the page.reducecalculates the running balance.- A click handler removes a transaction.
localStoragesaves the data so it survives a reload.
By the end you will have a working app that looks and behaves like a real budgeting tool.
🧱 The HTML
The page needs a form to enter a transaction, a list to show the transactions, and a place to display the balance. Here is the markup:
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <title>Expense Tracker</title> </head> <body> <h1>Expense Tracker</h1>
<h2>Balance: <span id="balance">$0.00</span></h2>
<form id="entry-form"> <input type="text" id="description" placeholder="Description" required /> <input type="number" id="amount" placeholder="Amount" step="0.01" required /> <button type="submit">Add</button> </form>
<ul id="transaction-list"></ul>
<script src="expense-tracker.js"></script> </body></html>Let’s walk through the parts that JavaScript will reach for:
- The
<span id="balance">holds the running total, and JavaScript updates its text every time the balance changes. - The
descriptioninput holds the label for the entry. - The
amountinput holds the value, where a positive number is income and a negative number is an expense. - The empty
<ul id="transaction-list">is the spot where JavaScript draws each transaction. - The
<script>tag at the bottom loads our code after the page elements exist.
Why a number input?
Setting type="number" lets users type negative values for expenses, and
step="0.01" allows cents. We still convert the value to a real number in
JavaScript before doing any math.
⚙️ The JavaScript
We will keep all the data in a single array of objects. Each object stores an id, a description, and an amount. Reading from and writing to that array drives the whole app.
Start by grabbing the elements and setting up the data:
const form = document.getElementById("entry-form");const descriptionInput = document.getElementById("description");const amountInput = document.getElementById("amount");const list = document.getElementById("transaction-list");const balanceDisplay = document.getElementById("balance");
// Load saved transactions, or start with an empty arraylet transactions = JSON.parse(localStorage.getItem("transactions")) || [];Let’s step through this setup line by line:
- The five
getElementByIdcalls grab the form, both inputs, the list, and the balance display so we can use them later. localStorage.getItem("transactions")reads any saved data, which comes back as text.JSON.parseturns that text back into a real array.getItemreturnsnullwhen nothing is saved yet, so the|| []gives us an empty array to start from.
Next, save the array whenever it changes:
function save() { localStorage.setItem("transactions", JSON.stringify(transactions));}JSON.stringify turns the array into text so localStorage can store it. We call save every time we add or remove a transaction.
Now render the list and the balance:
function render() { // Draw each transaction as a list item list.innerHTML = "";
transactions.forEach((transaction) => { const item = document.createElement("li"); item.textContent = `${transaction.description}: $${transaction.amount.toFixed(2)}`;
const removeButton = document.createElement("button"); removeButton.textContent = "Remove"; removeButton.addEventListener("click", () => removeTransaction(transaction.id));
item.appendChild(removeButton); list.appendChild(item); });
// Add every amount together to get the balance const balance = transactions.reduce((total, transaction) => total + transaction.amount, 0); balanceDisplay.textContent = `$${balance.toFixed(2)}`;}Here is what render does, step by step:
list.innerHTML = ""clears the list so we redraw from scratch instead of stacking duplicates.forEachloops over every transaction and builds an<li>for each one.- Inside the loop,
toFixed(2)formats the amount with two decimal places like real money. - Each item also gets a Remove button wired to
removeTransaction(transaction.id)on click. reducewalks through every transaction and adds the amounts together, starting from0, to produce the running balance.- The last line writes that balance into the
<span id="balance">.
Adding a transaction happens when the form is submitted:
form.addEventListener("submit", (event) => { event.preventDefault();
const description = descriptionInput.value.trim(); const amount = Number(amountInput.value);
if (description === "" || Number.isNaN(amount)) { return; }
transactions.push({ id: Date.now(), description: description, amount: amount, });
save(); render(); form.reset();});Let’s trace the submit handler in order:
event.preventDefault()stops the page from reloading when the form is submitted.- We read the description with
.trim()and convert the amount withNumber. - The
ifcheck stops early when the description is empty or the amount is not a real number. pushadds a new object onto the array, whereDate.now()supplies a unique timestampidso each transaction can be told apart.- Finally we
save,render, andform.reset()to clear the inputs.
Removing a transaction filters it out of the array by id:
function removeTransaction(id) { transactions = transactions.filter((transaction) => transaction.id !== id); save(); render();}filter builds a new array that keeps every transaction except the one whose id matches. We reassign transactions to that new array, save it, and render again.
Finally, draw whatever was loaded from storage when the page first opens:
render();Why store amount as a single number?
Keeping income as positive and expenses as negative means the balance is just
the sum of every amount. There is no separate income or expense math, which
makes reduce do all the work in one line.
Here is how the data flows through the app:
| Action | What happens to the array | What the user sees |
| Submit the form | push a new object | A new list item and updated balance |
| Click Remove | filter removes one object | The item disappears and balance updates |
| Reload the page | JSON.parse rebuilds the array | All transactions are still there |
🚀 Try It Yourself!
Once the basic tracker works, extend it with these enhancements:
- Add a category field to the form, store it on each transaction object, and show it in the list.
- Add filter buttons that show only income, only expenses, or all transactions by filtering the array before you render.
- Show separate totals for income and expenses by running
reducetwice, once over positive amounts and once over negative amounts. - Style income and expense items differently by adding a CSS class based on whether the amount is positive or negative.
🧩 What You’ve Learned
- ✅ An array of objects can hold all the data for a small app
- ✅
preventDefaultstops a form from reloading the page - ✅
forEachrenders a list andreducecalculates a total in one line - ✅
filterremoves an item by matching itsid - ✅
localStoragewithJSON.stringifyandJSON.parsemakes data survive a reload
🚀 What’s Next?
You have built an app that stores, displays, and persists data. Next we will apply the same patterns to a different kind of app. Let’s continue to Notes App.