Project: Expense Tracker

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 preventDefault adds a new transaction.
  • forEach renders the list on the page.
  • reduce calculates the running balance.
  • A click handler removes a transaction.
  • localStorage saves 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:

index.html
<!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 description input holds the label for the entry.
  • The amount input 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:

expense-tracker.js
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 array
let transactions = JSON.parse(localStorage.getItem("transactions")) || [];

Let’s step through this setup line by line:

  • The five getElementById calls 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.parse turns that text back into a real array.
  • getItem returns null when nothing is saved yet, so the || [] gives us an empty array to start from.

Next, save the array whenever it changes:

expense-tracker.js
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:

expense-tracker.js
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.
  • forEach loops 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.
  • reduce walks through every transaction and adds the amounts together, starting from 0, 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:

expense-tracker.js
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 with Number.
  • The if check stops early when the description is empty or the amount is not a real number.
  • push adds a new object onto the array, where Date.now() supplies a unique timestamp id so each transaction can be told apart.
  • Finally we save, render, and form.reset() to clear the inputs.

Removing a transaction filters it out of the array by id:

expense-tracker.js
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:

expense-tracker.js
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:

  1. Add a category field to the form, store it on each transaction object, and show it in the list.
  2. Add filter buttons that show only income, only expenses, or all transactions by filtering the array before you render.
  3. Show separate totals for income and expenses by running reduce twice, once over positive amounts and once over negative amounts.
  4. 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
  • preventDefault stops a form from reloading the page
  • forEach renders a list and reduce calculates a total in one line
  • filter removes an item by matching its id
  • localStorage with JSON.stringify and JSON.parse makes 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.

Share & Connect