React Expense Tracker

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.

  1. 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
  2. Go into the folder and install. This downloads the packages React needs.

    Terminal window
    cd expense-tracker
    npm install
  3. 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.html

The plan here is small but important:

  • App.jsx owns 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.
  • transactions is the current list. setTransactions is the only way to change it.
  • addTransaction takes a new transaction object and puts it at the front of the list.
  • deleteTransaction takes an id and removes the matching transaction.

Now the why, because this part matters a lot:

  • In addTransaction we write [transaction, ...prev]. The ...prev is the spread operator. It copies every old item into a brand-new array, and we put the new one first.
  • In deleteTransaction we use filter. 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, and type.
  • Each input reads its value from state and writes back through onChange. 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 return early and do nothing.
  • Math.abs(Number(amount)) turns the text from the input into a positive number. Number does the converting, and Math.abs drops 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 to App. We use Date.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:

  • formatCurrency uses toLocaleString with currency options. That’s a built-in browser feature that turns 3000 into $3,000.00 with 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 map over the transactions and draw one <li> for each.
  • The className switches between income and expense based 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 wrote onClick={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:

  • balance uses reduce. It walks the whole list and adds up every amount, starting from 0. Since expenses are negative, this gives the true balance directly.
  • income first keeps only the positive amounts with filter, then adds them up with reduce.
  • expense keeps only the negative amounts, then adds them. The result is a negative number, so we use Math.abs when we show it.

Then the bar:

  • totalFlow is income plus the size of expense. It’s how much money moved in total.
  • incomePercent is 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 inline style.

Now the big why, the lesson of this whole project:

  • reduce is 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 useState instead 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 use JSON.parse to turn that text back into an array.
  • If nothing is saved yet, we fall back to an empty array.

Then the saving:

  • useEffect runs code after the render. We give it [transactions] as the dependency list, so it runs every time the transactions change.
  • Inside, we JSON.stringify the array into text and save it under the key "transactions".

The why:

  • localStorage can only hold strings. That’s why JSON.stringify goes in and JSON.parse comes 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 :root block 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 .app card is centered with a max-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: width so 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.

Terminal window
npm run dev

Open 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 disagree
    const [balance, setBalance] = useState(0);
    // βœ… derive it from the one list
    const 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 place
    transactions.push(newOne);
    setTransactions(transactions);
    // βœ… build a new array
    setTransactions((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 first
    const 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 stable t.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 reduce into 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 reduce instead of storing them.
  • βœ… Format numbers as currency with toLocaleString.
  • βœ… Persist data across refreshes with useEffect and 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. 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. 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. 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. 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.

React E-commerce Product Listing

Share & Connect