React Expense Tracker
Table of Contents + β
You just built the React Movie Search Application, so you know how to fetch data and render a list. Now we build something you could actually use every day: a money tracker that adds it all up for you and remembers your data even after you close the tab.
π― What Weβre Building
Weβre building an expense tracker. Itβs a small dashboard where you type in money coming in and money going out, and it keeps a running total for you.
Hereβs what it can do:
- Add a transaction with a description, an amount, and a type (income or expense).
- Show every transaction in a list, each with its own delete button.
- Show a summary panel with your Balance, total Income, and total Expense.
- Color income green and expense red so you can read it instantly.
- Format every amount as proper currency, like
$1,250.00. - Show a small visual bar comparing income to expense.
- Save everything to the browser so itβs still there when you come back.
The most important idea is this: we do not store the Balance anywhere. We store only the list of transactions, and we calculate the Balance from that list every time. Thatβs called derived state, and it keeps your numbers from ever going out of sync.
Here is roughly what the finished app looks like when it runs:
Output
ββββββββββββββββββββββββββββββββββββββββββββββββ Expense Tracker ββ ββ Balance ββ $2,150.00 ββ ββ Income Expense ββ $3,000.00 $850.00 ββ ββ [ββββββββββββββββββββββββββ] ββ ββ Description Amount Type βΎ [ Add ] ββ ββ Salary +$3,000.00 β ββ Groceries -$120.00 β ββ Internet bill -$60.00 β ββ Dinner out -$670.00 β ββββββββββββββββββββββββββββββββββββββββββββββββποΈ Project Setup
Weβll use Vite. Itβs a tool that creates a ready-to-run React project for you and gives you a fast dev server. Run these commands in your terminal.
-
Create the project. This makes a new React project in a folder called
expense-tracker.Terminal window npm create vite@latest expense-tracker -- --template react -
Go into the folder and install. This downloads the packages React needs.
Terminal window cd expense-trackernpm install -
Start the dev server. This opens the app in your browser at
http://localhost:5173.Terminal window npm run dev
Now letβs look at where each piece will live. Weβll keep it simple and put our components in a components folder.
expense-tracker/βββ src/β βββ components/β β βββ Summary.jsx # balance, income, expense, the barβ β βββ TransactionForm.jsx # the add formβ β βββ TransactionList.jsx # the list + delete buttonsβ βββ App.jsx # holds the state, ties it togetherβ βββ App.css # all our stylesβ βββ main.jsx # Vite's entry file (leave as-is)βββ index.htmlThe plan here is small but important:
App.jsxowns the one piece of real state: the list of transactions.- The three components below it just receive data and call functions. They donβt own the data.
This is the normal React pattern. The parent holds the truth, and the children display it and report events back up.
πΎ The Shape of a Transaction
Before any code, letβs agree on what one transaction is. Each one is a plain object that looks like this:
{ id: 1718000000000, // a unique id (we'll use the current time) text: "Salary", // the description amount: 3000, // positive for income, negative for expense type: "income" // "income" or "expense"}Hereβs the key trick that makes the math easy:
- Income is stored as a positive number.
- Expense is stored as a negative number.
So amount for βGroceriesβ is -120, not 120. Because of that, the Balance is just the sum of every amount. No special cases. Weβll handle that conversion in the form so the rest of the app stays simple.
π§± The App Shell and State
Letβs start with App.jsx. Weβll set up the state and the two functions that change it, then drop in placeholders for the three components. Replace the whole contents of src/App.jsx with this.
import { useState } from "react";import Summary from "./components/Summary";import TransactionForm from "./components/TransactionForm";import TransactionList from "./components/TransactionList";import "./App.css";
function App() { const [transactions, setTransactions] = useState([]);
function addTransaction(transaction) { setTransactions((prev) => [transaction, ...prev]); }
function deleteTransaction(id) { setTransactions((prev) => prev.filter((t) => t.id !== id)); }
return ( <div className="app"> <h1>Expense Tracker</h1> <Summary transactions={transactions} /> <TransactionForm onAdd={addTransaction} /> <TransactionList transactions={transactions} onDelete={deleteTransaction} /> </div> );}
export default App;Letβs walk through what each part does:
useState([])creates our state. It starts as an empty array because at first there are no transactions.transactionsis the current list.setTransactionsis the only way to change it.addTransactiontakes a new transaction object and puts it at the front of the list.deleteTransactiontakes an id and removes the matching transaction.
Now the why, because this part matters a lot:
- In
addTransactionwe write[transaction, ...prev]. The...previs the spread operator. It copies every old item into a brand-new array, and we put the new one first. - In
deleteTransactionwe usefilter. It builds a new array with everything except the one whose id matches.
Both of these make a new array instead of changing the old one. Thatβs called immutable updates. React decides whether to re-render by checking if the array is a new one. If you push into the old array, React often wonβt notice and your screen wonβt update. New array every time is the safe habit.
One more detail: we pass (prev) => ... to setTransactions instead of using transactions directly. This is the updater function form. It hands you the most recent value, so your update is always based on fresh data. Itβs the reliable way to update state that depends on the old state.
π The Add Form
Now the form. This is a controlled form, which means React state holds the value of every input. The input shows whatβs in state, and typing updates state. Create src/components/TransactionForm.jsx.
import { useState } from "react";
function TransactionForm({ onAdd }) { const [text, setText] = useState(""); const [amount, setAmount] = useState(""); const [type, setType] = useState("income");
function handleSubmit(e) { e.preventDefault();
if (text.trim() === "" || amount === "") { return; }
const numericAmount = Math.abs(Number(amount)); const signedAmount = type === "income" ? numericAmount : -numericAmount;
onAdd({ id: Date.now(), text: text.trim(), amount: signedAmount, type: type, });
setText(""); setAmount(""); setType("income"); }
return ( <form className="form" onSubmit={handleSubmit}> <input type="text" placeholder="Description" value={text} onChange={(e) => setText(e.target.value)} /> <input type="number" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} /> <select value={type} onChange={(e) => setType(e.target.value)}> <option value="income">Income</option> <option value="expense">Expense</option> </select> <button type="submit">Add</button> </form> );}
export default TransactionForm;Hereβs whatβs happening field by field:
- Each input has its own piece of state:
text,amount, andtype. - Each input reads its
valuefrom state and writes back throughonChange. Thatβs the controlled-input loop. - The
<select>lets the person pick income or expense.
Now the submit handler, step by step:
e.preventDefault()stops the browser from reloading the page, which is its default behavior for forms.- We check the fields arenβt empty. If they are, we
returnearly and do nothing. Math.abs(Number(amount))turns the text from the input into a positive number.Numberdoes the converting, andMath.absdrops any minus sign the person typed.- Then we decide the sign: income stays positive, expense becomes negative. Thatβs our earlier rule in action.
onAdd({...})sends the finished transaction up toApp. We useDate.now()for the id because itβs a different number every time.- Finally we clear the three fields so the form is ready for the next entry.
The why here:
- The form doesnβt keep the list of transactions. It only knows about the one row being typed right now.
- It reports the result upward through
onAdd. The parent owns the list. This keeps each component with one clear job.
Tip
Inputs always give you a string, even a number input. Thatβs why we wrap the amount in Number(...) before doing math. If you skip that, "100" + 50 becomes "10050" instead of 150.
π The Transaction List
Now we show the transactions. Each row has the description, the amount in currency, and a delete button. Create src/components/TransactionList.jsx.
function formatCurrency(value) { return value.toLocaleString("en-US", { style: "currency", currency: "USD", });}
function TransactionList({ transactions, onDelete }) { if (transactions.length === 0) { return <p className="empty">No transactions yet. Add your first one above.</p>; }
return ( <ul className="list"> {transactions.map((t) => ( <li key={t.id} className={t.amount > 0 ? "item income" : "item expense"} > <span className="item-text">{t.text}</span> <span className="item-amount"> {t.amount > 0 ? "+" : "-"} {formatCurrency(Math.abs(t.amount))} </span> <button className="delete-btn" onClick={() => onDelete(t.id)} aria-label="Delete transaction" > β </button> </li> ))} </ul> );}
export default TransactionList;Walking through it:
formatCurrencyusestoLocaleStringwith currency options. Thatβs a built-in browser feature that turns3000into$3,000.00with the commas and the dollar sign for you.- If the list is empty, we show a friendly message instead of a blank space.
- Otherwise we
mapover the transactions and draw one<li>for each. - The
classNameswitches betweenincomeandexpensebased on whether the amount is positive. The CSS will color these green and red. - We show a
+for income and a-for expense, then the amount with its sign stripped (we already show the sign ourselves). - The delete button calls
onDelete(t.id). Notice itβs wrapped in an arrow function so it only runs when clicked, not while rendering.
The why behind two small things:
key={t.id}gives React a stable id for each row. It uses this to track which item is which, so deleting the right one is fast and correct. Never use the array index as the key when items can be deleted.onClick={() => onDelete(t.id)}is wrapped. If you wroteonClick={onDelete(t.id)}it would call delete immediately during render, which is wrong and would loop.
Note
Youβll notice formatCurrency shows up in both TransactionList.jsx and Summary.jsx. We copied it on purpose to keep each file readable on its own. In a real project youβd move it to a shared file like src/utils/format.js and import it in both places, so thereβs only one copy to maintain.
π The Summary Panel (Derived Totals)
This is the heart of the app. We take the list of transactions and compute Balance, Income, and Expense from it. We never store these numbers. Create src/components/Summary.jsx.
function formatCurrency(value) { return value.toLocaleString("en-US", { style: "currency", currency: "USD", });}
function Summary({ transactions }) { const balance = transactions.reduce((sum, t) => sum + t.amount, 0);
const income = transactions .filter((t) => t.amount > 0) .reduce((sum, t) => sum + t.amount, 0);
const expense = transactions .filter((t) => t.amount < 0) .reduce((sum, t) => sum + t.amount, 0);
const totalFlow = income + Math.abs(expense); const incomePercent = totalFlow === 0 ? 50 : (income / totalFlow) * 100;
return ( <div className="summary"> <div className="balance"> <span className="label">Balance</span> <span className="balance-value">{formatCurrency(balance)}</span> </div>
<div className="totals"> <div className="total income-box"> <span className="label">Income</span> <span className="amount">{formatCurrency(income)}</span> </div> <div className="total expense-box"> <span className="label">Expense</span> <span className="amount">{formatCurrency(Math.abs(expense))}</span> </div> </div>
<div className="bar"> <div className="bar-income" style={{ width: `${incomePercent}%` }} ></div> <div className="bar-expense" style={{ width: `${100 - incomePercent}%` }} ></div> </div> </div> );}
export default Summary;Hereβs how the numbers come together:
balanceusesreduce. It walks the whole list and adds up everyamount, starting from0. Since expenses are negative, this gives the true balance directly.incomefirst keeps only the positive amounts withfilter, then adds them up withreduce.expensekeeps only the negative amounts, then adds them. The result is a negative number, so we useMath.abswhen we show it.
Then the bar:
totalFlowis income plus the size of expense. Itβs how much money moved in total.incomePercentis incomeβs share of that, as a percentage. If nothing has happened yet, we default to 50 so the bar looks balanced and empty.- The two
<div>widths come from that percentage, set with an inlinestyle.
Now the big why, the lesson of this whole project:
reduceis the tool for reducing a list to a single value. Here that value is a total. You give it a function and a starting point, and it folds the list into a single result.- Because Balance, Income, and Expense are all calculated from
transactions, they can never disagree with the list. Add a row and they update on the next render automatically.
Note
You might be tempted to keep balance in its own useState and add to it whenever a transaction comes in. Donβt. The moment you delete a transaction, youβd have to remember to subtract it too, and one missed update means the number lies. Deriving it removes that whole class of bugs.
π½ Saving to localStorage
Right now everything disappears when you refresh. Letβs fix that. localStorage is a small box of storage in the browser where you can keep text between visits. Weβll save the transactions whenever they change, and load them back when the app starts.
Open src/App.jsx and update the imports and the state, and add a useEffect.
import { useState, useEffect } from "react";import Summary from "./components/Summary";import TransactionForm from "./components/TransactionForm";import TransactionList from "./components/TransactionList";import "./App.css";
function App() { const [transactions, setTransactions] = useState(() => { const saved = localStorage.getItem("transactions"); return saved ? JSON.parse(saved) : []; });
useEffect(() => { localStorage.setItem("transactions", JSON.stringify(transactions)); }, [transactions]);
function addTransaction(transaction) { setTransactions((prev) => [transaction, ...prev]); }
function deleteTransaction(id) { setTransactions((prev) => prev.filter((t) => t.id !== id)); }
return ( <div className="app"> <h1>Expense Tracker</h1> <Summary transactions={transactions} /> <TransactionForm onAdd={addTransaction} /> <TransactionList transactions={transactions} onDelete={deleteTransaction} /> </div> );}
export default App;Two new pieces here. First the loading:
- We pass a function to
useStateinstead of a plain value. This is the lazy initializer. React runs it only once, on the first render. - Inside, we read
"transactions"from localStorage. localStorage only stores text, so we useJSON.parseto turn that text back into an array. - If nothing is saved yet, we fall back to an empty array.
Then the saving:
useEffectruns code after the render. We give it[transactions]as the dependency list, so it runs every time the transactions change.- Inside, we
JSON.stringifythe array into text and save it under the key"transactions".
The why:
- localStorage can only hold strings. Thatβs why
JSON.stringifygoes in andJSON.parsecomes out. Theyβre the bridge between your array and the text the browser can store. - The dependency array
[transactions]is what ties the save to the data. Change the list, the effect runs, the new list is saved. Nothing else triggers it.
Caution
Donβt put an empty [] dependency array here and expect saving to work. With [] the effect runs only once at the start, so later changes never get saved. The dependency must include the value youβre reacting to.
π¨ Styling
Now letβs make it look like a real dashboard. Replace everything in src/App.css with this. The goal is a clean centered card, clear money colors, and a summary that draws the eye.
:root { --green: #16a34a; --red: #dc2626; --bg: #0f172a; --card: #ffffff; --muted: #64748b; --border: #e2e8f0;}
* { box-sizing: border-box; margin: 0; padding: 0;}
body { font-family: system-ui, sans-serif; background: var(--bg); min-height: 100vh; display: flex; justify-content: center; padding: 32px 16px;}
.app { width: 100%; max-width: 440px; background: var(--card); border-radius: 16px; padding: 24px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);}
.app h1 { font-size: 1.4rem; text-align: center; margin-bottom: 20px; color: #1e293b;}
/* Summary */.summary { background: #f8fafc; border-radius: 12px; padding: 20px; margin-bottom: 20px;}
.balance { text-align: center; margin-bottom: 16px;}
.label { display: block; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted);}
.balance-value { font-size: 2rem; font-weight: 700; color: #1e293b;}
.totals { display: flex; gap: 12px; margin-bottom: 16px;}
.total { flex: 1; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 12px; text-align: center;}
.income-box .amount { color: var(--green); font-weight: 700;}
.expense-box .amount { color: var(--red); font-weight: 700;}
.bar { display: flex; height: 10px; border-radius: 999px; overflow: hidden; background: var(--border);}
.bar-income { background: var(--green); transition: width 0.3s ease;}
.bar-expense { background: var(--red); transition: width 0.3s ease;}
/* Form */.form { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px;}
.form input,.form select { padding: 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 0.9rem;}
.form input[type="text"] { flex: 1 1 100%;}
.form input[type="number"],.form select { flex: 1;}
.form button { flex: 1 1 100%; padding: 12px; border: none; border-radius: 8px; background: #1e293b; color: #fff; font-weight: 600; cursor: pointer;}
.form button:hover { background: #334155;}
/* List */.list { list-style: none;}
.empty { text-align: center; color: var(--muted); padding: 16px 0;}
.item { display: flex; align-items: center; gap: 12px; background: var(--card); border: 1px solid var(--border); border-left-width: 4px; border-radius: 8px; padding: 12px; margin-bottom: 8px;}
.item.income { border-left-color: var(--green);}
.item.expense { border-left-color: var(--red);}
.item-text { flex: 1;}
.item-amount { font-weight: 700;}
.item.income .item-amount { color: var(--green);}
.item.expense .item-amount { color: var(--red);}
.delete-btn { border: none; background: transparent; color: var(--muted); cursor: pointer; font-size: 1rem; line-height: 1;}
.delete-btn:hover { color: var(--red);}The key styling choices, in plain terms:
- The
:rootblock holds our color variables. Defining green and red once means every part of the app uses the exact same shade, and you can re-theme the whole app by changing a couple of lines. - The
.appcard is centered with amax-width, so it looks good on a phone and on a wide screen without stretching too far. - Each list item has a thick colored
border-left. That little stripe tells you income from expense even before you read the number. - The bar uses
transition: widthso when the numbers change, the colored sections slide smoothly instead of jumping.
π§© The Complete Code
Hereβs every file in one place so you have the whole working app.
src/App.jsx:
import { useState, useEffect } from "react";import Summary from "./components/Summary";import TransactionForm from "./components/TransactionForm";import TransactionList from "./components/TransactionList";import "./App.css";
function App() { const [transactions, setTransactions] = useState(() => { const saved = localStorage.getItem("transactions"); return saved ? JSON.parse(saved) : []; });
useEffect(() => { localStorage.setItem("transactions", JSON.stringify(transactions)); }, [transactions]);
function addTransaction(transaction) { setTransactions((prev) => [transaction, ...prev]); }
function deleteTransaction(id) { setTransactions((prev) => prev.filter((t) => t.id !== id)); }
return ( <div className="app"> <h1>Expense Tracker</h1> <Summary transactions={transactions} /> <TransactionForm onAdd={addTransaction} /> <TransactionList transactions={transactions} onDelete={deleteTransaction} /> </div> );}
export default App;src/components/Summary.jsx:
function formatCurrency(value) { return value.toLocaleString("en-US", { style: "currency", currency: "USD", });}
function Summary({ transactions }) { const balance = transactions.reduce((sum, t) => sum + t.amount, 0);
const income = transactions .filter((t) => t.amount > 0) .reduce((sum, t) => sum + t.amount, 0);
const expense = transactions .filter((t) => t.amount < 0) .reduce((sum, t) => sum + t.amount, 0);
const totalFlow = income + Math.abs(expense); const incomePercent = totalFlow === 0 ? 50 : (income / totalFlow) * 100;
return ( <div className="summary"> <div className="balance"> <span className="label">Balance</span> <span className="balance-value">{formatCurrency(balance)}</span> </div>
<div className="totals"> <div className="total income-box"> <span className="label">Income</span> <span className="amount">{formatCurrency(income)}</span> </div> <div className="total expense-box"> <span className="label">Expense</span> <span className="amount">{formatCurrency(Math.abs(expense))}</span> </div> </div>
<div className="bar"> <div className="bar-income" style={{ width: `${incomePercent}%` }} ></div> <div className="bar-expense" style={{ width: `${100 - incomePercent}%` }} ></div> </div> </div> );}
export default Summary;src/components/TransactionForm.jsx:
import { useState } from "react";
function TransactionForm({ onAdd }) { const [text, setText] = useState(""); const [amount, setAmount] = useState(""); const [type, setType] = useState("income");
function handleSubmit(e) { e.preventDefault();
if (text.trim() === "" || amount === "") { return; }
const numericAmount = Math.abs(Number(amount)); const signedAmount = type === "income" ? numericAmount : -numericAmount;
onAdd({ id: Date.now(), text: text.trim(), amount: signedAmount, type: type, });
setText(""); setAmount(""); setType("income"); }
return ( <form className="form" onSubmit={handleSubmit}> <input type="text" placeholder="Description" value={text} onChange={(e) => setText(e.target.value)} /> <input type="number" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} /> <select value={type} onChange={(e) => setType(e.target.value)}> <option value="income">Income</option> <option value="expense">Expense</option> </select> <button type="submit">Add</button> </form> );}
export default TransactionForm;src/components/TransactionList.jsx:
function formatCurrency(value) { return value.toLocaleString("en-US", { style: "currency", currency: "USD", });}
function TransactionList({ transactions, onDelete }) { if (transactions.length === 0) { return <p className="empty">No transactions yet. Add your first one above.</p>; }
return ( <ul className="list"> {transactions.map((t) => ( <li key={t.id} className={t.amount > 0 ? "item income" : "item expense"} > <span className="item-text">{t.text}</span> <span className="item-amount"> {t.amount > 0 ? "+" : "-"} {formatCurrency(Math.abs(t.amount))} </span> <button className="delete-btn" onClick={() => onDelete(t.id)} aria-label="Delete transaction" > β </button> </li> ))} </ul> );}
export default TransactionList;The CSS is in the Styling section above. Copy it into src/App.css.
βΆοΈ Run It
Make sure youβre in the project folder, then start the dev server.
npm run devOpen http://localhost:5173. Add βSalaryβ with amount 3000 and type Income. Add βGroceriesβ with 120 and type Expense. You should see the totals update right away, the bar shift, and the rows color themselves.
Output
Balance$2,880.00
Income Expense$3,000.00 $120.00
[ββββββββββββββββββββββββββββ]
Salary +$3,000.00 βGroceries -$120.00 βNow refresh the page. Your transactions are still there, because we saved them to localStorage. Thatβs the whole app working.
β οΈ Common Mistakes
A few traps that catch people building this:
- Storing the balance in state. Then it drifts out of sync on delete.
// β a second source of truth that can disagreeconst [balance, setBalance] = useState(0);// β derive it from the one listconst balance = transactions.reduce((sum, t) => sum + t.amount, 0);
- Mutating the array instead of replacing it. React wonβt re-render.
// β changes the old array in placetransactions.push(newOne);setTransactions(transactions);// β build a new arraysetTransactions((prev) => [newOne, ...prev]);
- Doing math on a string from the input. You get text joining, not addition.
// β "100" + "50" becomes "10050"const total = amount + other;// β convert firstconst total = Number(amount) + Number(other);
- Calling the delete handler during render.
// β runs immediately, every render<button onClick={onDelete(t.id)}>β</button>// β run only on click<button onClick={() => onDelete(t.id)}>β</button>
- Using the array index as the
key. After a delete, React can mix up rows. Use the stablet.id.
β Best Practices
The habits this project follows that are worth keeping:
- Keep one source of truth. Store transactions, derive everything else.
- Always update state immutably with spread and
filter, never by mutating. - Use the updater function form (
setTransactions((prev) => ...)) when the new value depends on the old one. - Give each component one job: the form collects, the list shows, the summary computes.
- Format and convert at the edges. Convert the input to a number when it comes in, format to currency when it goes out.
π Extend It / Make It Your Own
Once it works, try adding to it:
- Categories. Add a category dropdown (Food, Rent, Travel) and show a breakdown of spending per category using
reduceinto an object. - Edit a transaction. Add an edit button that loads a row back into the form.
- Filter the list. Add buttons to show only income or only expense.
- A real chart. Swap the simple bar for a pie chart using a small library.
- Multiple currencies. Let the person pick the currency and pass it into
formatCurrency. - A monthly view. Store a date on each transaction and group totals by month.
π§© What Youβve Learned
Hereβs what you can now do:
- β
Manage a list in state and update it immutably with spread and
filter. - β Build controlled form inputs that React fully owns.
- β
Derive totals from state with
reduceinstead of storing them. - β
Format numbers as currency with
toLocaleString. - β
Persist data across refreshes with
useEffectand localStorage. - β Split an app into a parent that owns state and children that display it.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Why do we calculate the balance with reduce instead of keeping it in its own useState?
Why: Balance is derived state. Computing it from the one list of transactions means it can never disagree with that list, even after a delete.
- 2
Why do we use filter to delete a transaction?
Why: filter builds a brand-new array that excludes the matched id. A new array lets React detect the change and re-render.
- 3
What does the dependency array [transactions] do in our useEffect?
Why: The effect re-runs whenever a value in its dependency array changes, so saving happens each time the transaction list updates.
- 4
Why do we wrap the amount input value in Number() before doing math?
Why: Input values are strings. Without converting, "100" + "50" becomes "10050". Number() turns the text into an actual number first.
π Whatβs Next?
Youβve built a full data app with derived totals and persistence. Next weβll handle a grid of products with filtering and sorting.