Project: Expense Tracker

React · Lesson 29 of 29 8 min read Updated

In this final project you will build an expense tracker for Kabir, a software trainee in Pune who wants to see where his salary goes each month. Compared with the to-do app, this project adds three ideas that real apps need: shared state through context, form validation with useActionState, and summaries calculated from data, such as totals per category with a small bar chart.

The app lets Kabir add an expense with a title, amount, category and date, shows a total and a category breakdown for the chosen month, lists the expenses with a delete button, and remembers everything in the browser. As before, you will build it in steps, and the complete app is at the end.

Step 1: shape the data and the components

Each expense is a plain object:

JavaScript
{ id: "e3", title: "Groceries", amount: 2350, category: "Food", date: "2026-10-05" }

Dates are stored as YYYY-MM-DD strings, exactly what an <input type="date"> produces. This format also sorts correctly as text, and the month is simply the first seven characters ("2026-10"). Amounts are whole rupees, so there are no floating-point surprises.

The components:

  • ExpensesProvider: owns the list of expenses (reducer) and shares it through context.
  • AddExpenseForm: the form, with validation.
  • Tracker: the page; it owns the selected month.
  • Summary: the total and the category bars for the visible expenses.
  • ExpenseList: the list with delete buttons.

Several components need the expenses or the dispatch function, at different depths. Rather than passing them through every level, put them in Context.

Step 2: a reducer and a provider

The reducer is small: expenses can be added and deleted. New expenses go to the front of the array.

React JSX
function expensesReducer(expenses, action) {
  switch (action.type) {
    case "added":
      return [action.expense, ...expenses];
    case "deleted":
      return expenses.filter((e) => e.id !== action.id);
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

const ExpensesContext = createContext(null);

function ExpensesProvider({ children }) {
  const [expenses, dispatch] = useReducer(expensesReducer, undefined, loadExpenses);

  useEffect(() => {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(expenses));
    } catch {
      // storage unavailable
    }
  }, [expenses]);

  return <ExpensesContext value={{ expenses, dispatch }}>{children}</ExpensesContext>;
}

function useExpenses() {
  const value = useContext(ExpensesContext);
  if (!value) throw new Error("useExpenses must be used inside <ExpensesProvider>");
  return value;
}

The provider combines three things you already know: a reducer with an init function (loadExpenses reads saved data once, falling back to sample expenses), an effect that saves on every change, and React 19's <ExpensesContext value={...}> provider syntax. The custom hook useExpenses gives every component a one-line way to reach the data and fails loudly if the provider is missing.

Step 3: a validated form with useActionState

The form needs validation messages: a title, an amount above zero, a valid category and a date. A plain function validates the FormData and returns the cleaned values plus any errors. Keeping validation in its own pure function makes it easy to test:

React JSX
function validate(formData) {
  const title = String(formData.get("title")).trim();
  const amount = Number(formData.get("amount"));
  const category = String(formData.get("category"));
  const date = String(formData.get("date"));
  const errors = {};
  if (title.length < 2) errors.title = "Enter a short description.";
  if (!Number.isFinite(amount) || amount <= 0) errors.amount = "Amount must be more than 0.";
  if (!CATEGORIES.includes(category)) errors.category = "Choose a category.";
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) errors.date = "Choose a date.";
  return { title, amount: Math.round(amount), category, date, errors };
}

The form component connects it with useActionState. The action either returns the errors, or dispatches the new expense and returns a success message:

React JSX
function addExpense(previous, formData) {
  const { errors, ...expense } = validate(formData);
  if (Object.keys(errors).length > 0) {
    return { errors, values: Object.fromEntries(formData), attempt: previous.attempt + 1 };
  }
  dispatch({ type: "added", expense: { id: crypto.randomUUID(), ...expense } });
  return { errors: {}, message: `Added ${expense.title}`, attempt: previous.attempt + 1 };
}

const [state, formAction] = useActionState(addExpense, { errors: {}, attempt: 0 });

There is one subtle problem to solve. After a form action finishes, React resets the uncontrolled fields, even when validation failed. Kabir would lose everything he typed because of one wrong field. The fix has two parts: the action returns the submitted values, the fields use them as defaultValue, and the <form> gets key={state.attempt}. Changing the key makes React build fresh fields after each submit, so they start with the values from the state: the typed values after an error, or empty fields after a success. This is the "reset with a key" technique from State with useState.

Step 4: filter by month

The selected month is state in Tracker. The list of available months and the visible expenses are derived from the data on every render:

React JSX
const months = [...new Set(expenses.map((e) => e.date.slice(0, 7)))].toSorted().toReversed();
const [month, setMonth] = useState(months[0] ?? "2026-10");
const visible = expenses
  .filter((e) => e.date.startsWith(month))
  .toSorted((a, b) => b.date.localeCompare(a.date));

A Set removes duplicate months, toSorted().toReversed() puts the newest first, and none of these methods change the original array. The visible expenses are passed to Summary and ExpenseList as props, so both always show the same month.

Step 5: totals per category

Summary turns a list of expenses into numbers. Object.groupBy (ES2024) groups the expenses by category, then each group is reduced to a sum and the result is sorted from biggest to smallest:

React JSX
const total = expenses.reduce((sum, e) => sum + e.amount, 0);
const byCategory = Object.entries(Object.groupBy(expenses, (e) => e.category))
  .map(([category, list]) => [category, list.reduce((s, e) => s + e.amount, 0)])
  .toSorted((a, b) => b[1] - a[1]);

Each category becomes a bar whose width is proportional to the largest category, using an inline style as in Styling React Apps. Money is formatted with Intl.NumberFormat and the en-IN locale, which writes amounts the Indian way, such as ₹12,000 and ₹1,25,000.

Step 6: the list with delete buttons

ExpenseList reads dispatch from context, so Tracker does not need to pass a delete handler down:

React JSX
function ExpenseList({ expenses }) {
  const { dispatch } = useExpenses();
  if (expenses.length === 0) return <p>No expenses for this month.</p>;
  return (
    <ul>
      {expenses.map((e) => (
        <li key={e.id}>
          {e.title} ({e.category}) {rupees.format(e.amount)}{" "}
          <button onClick={() => dispatch({ type: "deleted", id: e.id })} aria-label={`Delete ${e.title}`}>x</button>
        </li>
      ))}
    </ul>
  );
}

The aria-label gives the small "x" button a meaningful name for screen readers ("Delete Groceries"), and it also makes the button easy to find in tests with getByRole("button", { name: "Delete Groceries" }).

The complete app

Here is the whole tracker. Try submitting the form empty to see the validation messages, then add an expense such as "Chai and samosa" for ₹60. Delete one, switch to September, and run the example again to see that your data was saved.

React JSXTry it yourself
import { createContext, useContext, useReducer, useEffect, useActionState, useState } from "react";
import { createRoot } from "react-dom/client";

// ---------- data helpers ----------
const CATEGORIES = ["Food", "Travel", "Rent", "Bills", "Shopping", "Other"];
const STORAGE_KEY = "expense-tracker-v1";
const rupees = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
const today = () => new Date().toLocaleDateString("en-CA"); // local date as YYYY-MM-DD

function expensesReducer(expenses, action) {
  switch (action.type) {
    case "added":
      return [action.expense, ...expenses];
    case "deleted":
      return expenses.filter((e) => e.id !== action.id);
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function loadExpenses() {
  try {
    const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
    if (Array.isArray(saved)) return saved;
  } catch {
    // ignore and use sample data
  }
  return [
    { id: "e1", title: "Monthly rent", amount: 12000, category: "Rent", date: "2026-10-01" },
    { id: "e2", title: "Metro card recharge", amount: 500, category: "Travel", date: "2026-10-03" },
    { id: "e3", title: "Groceries", amount: 2350, category: "Food", date: "2026-10-05" },
    { id: "e4", title: "Electricity bill", amount: 1480, category: "Bills", date: "2026-09-28" },
  ];
}

// ---------- context ----------
const ExpensesContext = createContext(null);

function ExpensesProvider({ children }) {
  const [expenses, dispatch] = useReducer(expensesReducer, undefined, loadExpenses);

  useEffect(() => {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(expenses));
    } catch {
      // storage unavailable
    }
  }, [expenses]);

  return <ExpensesContext value={{ expenses, dispatch }}>{children}</ExpensesContext>;
}

function useExpenses() {
  const value = useContext(ExpensesContext);
  if (!value) throw new Error("useExpenses must be used inside <ExpensesProvider>");
  return value;
}

// ---------- add form ----------
function validate(formData) {
  const title = String(formData.get("title")).trim();
  const amount = Number(formData.get("amount"));
  const category = String(formData.get("category"));
  const date = String(formData.get("date"));
  const errors = {};
  if (title.length < 2) errors.title = "Enter a short description.";
  if (!Number.isFinite(amount) || amount <= 0) errors.amount = "Amount must be more than 0.";
  if (!CATEGORIES.includes(category)) errors.category = "Choose a category.";
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) errors.date = "Choose a date.";
  return { title, amount: Math.round(amount), category, date, errors };
}

function AddExpenseForm() {
  const { dispatch } = useExpenses();

  function addExpense(previous, formData) {
    const { errors, ...expense } = validate(formData);
    if (Object.keys(errors).length > 0) {
      // keep what the user typed; a new key below rebuilds the fields with these defaults
      return { errors, values: Object.fromEntries(formData), attempt: previous.attempt + 1 };
    }
    dispatch({ type: "added", expense: { id: crypto.randomUUID(), ...expense } });
    return { errors: {}, message: `Added ${expense.title}`, attempt: previous.attempt + 1 };
  }

  const [state, formAction] = useActionState(addExpense, { errors: {}, attempt: 0 });
  const err = state.errors;
  const v = state.values ?? {};

  return (
    <form key={state.attempt} action={formAction} style={{ display: "grid", gap: 6, gridTemplateColumns: "1fr 1fr" }}>
      <input name="title" defaultValue={v.title} placeholder="What did you spend on?" aria-label="Title" />
      <input name="amount" type="number" defaultValue={v.amount} placeholder="Amount in ₹" aria-label="Amount" />
      <select name="category" defaultValue={v.category ?? "Food"} aria-label="Category">
        {CATEGORIES.map((c) => <option key={c}>{c}</option>)}
      </select>
      <input name="date" type="date" defaultValue={v.date ?? today()} aria-label="Date" />
      <button style={{ gridColumn: "span 2" }}>Add expense</button>
      {Object.values(err).map((m) => (
        <small key={m} style={{ color: "crimson", gridColumn: "span 2" }}>{m}</small>
      ))}
      {state.message && <small style={{ color: "green", gridColumn: "span 2" }}>{state.message}</small>}
    </form>
  );
}

// ---------- summary and list ----------
function Summary({ expenses }) {
  const total = expenses.reduce((sum, e) => sum + e.amount, 0);
  const byCategory = Object.entries(Object.groupBy(expenses, (e) => e.category))
    .map(([category, list]) => [category, list.reduce((s, e) => s + e.amount, 0)])
    .toSorted((a, b) => b[1] - a[1]);
  const largest = byCategory[0]?.[1] ?? 0;

  return (
    <section>
      <h3>Total: {rupees.format(total)}</h3>
      {byCategory.map(([category, amount]) => (
        <div key={category} style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
          <span style={{ width: 70 }}>{category}</span>
          <div style={{ background: "#087ea4", height: 12, width: `${(amount / largest) * 160}px`, borderRadius: 3 }} />
          <span>{rupees.format(amount)}</span>
        </div>
      ))}
    </section>
  );
}

function ExpenseList({ expenses }) {
  const { dispatch } = useExpenses();
  if (expenses.length === 0) return <p>No expenses for this month.</p>;
  return (
    <ul style={{ paddingLeft: 18 }}>
      {expenses.map((e) => (
        <li key={e.id}>
          {e.date.slice(8)}/{e.date.slice(5, 7)}: {e.title} ({e.category}) {rupees.format(e.amount)}{" "}
          <button onClick={() => dispatch({ type: "deleted", id: e.id })} aria-label={`Delete ${e.title}`}>x</button>
        </li>
      ))}
    </ul>
  );
}

function Tracker() {
  const { expenses } = useExpenses();
  const months = [...new Set(expenses.map((e) => e.date.slice(0, 7)))].toSorted().toReversed();
  const [month, setMonth] = useState(months[0] ?? "2026-10");
  const visible = expenses.filter((e) => e.date.startsWith(month)).toSorted((a, b) => b.date.localeCompare(a.date));

  return (
    <main style={{ maxWidth: 460, fontFamily: "system-ui" }}>
      <h2>Kabir's expense tracker</h2>
      <AddExpenseForm />
      <p>
        <label>
          Month{" "}
          <select value={month} onChange={(e) => setMonth(e.target.value)}>
            {months.map((m) => <option key={m} value={m}>{m}</option>)}
          </select>
        </label>
      </p>
      <Summary expenses={visible} />
      <ExpenseList expenses={visible} />
    </main>
  );
}

createRoot(document.getElementById("root")).render(
  <ExpensesProvider>
    <Tracker />
  </ExpensesProvider>
);

Trace what happens when Kabir adds an expense. The form action validates the data, dispatches "added", and returns a success state with a new attempt. The reducer returns a new array; every component that reads the context re-renders; Tracker recalculates the months and visible expenses; Summary recalculates the totals; the form is rebuilt empty because its key changed; and the provider's effect saves the new list.

Ideas to extend the app

  • Add a monthly budget per category and show a warning colour when spending crosses it.
  • Add an "Edit" button that fills the form with an existing expense and dispatches an "edited" action.
  • Export the visible month as a CSV file with a Blob and a download link.
  • Move the app into a Vite project with one file per component, add React Router pages for "Expenses" and "Reports", and write Vitest tests for validate and expensesReducer.
  • Rewrite it in TypeScript with an Expense type and a Category union type.

Key points

  • Plan the data shape first: ISO date strings and whole-rupee amounts keep sorting and maths simple.
  • A reducer in a provider component, exposed through a custom hook, shares state across the app.
  • useActionState returns validation errors as state; keep validation in a pure function.
  • Form actions reset uncontrolled fields; return the submitted values and change the form's key to keep them after an error.
  • Months, totals and category breakdowns are derived with Set, filter, Object.groupBy and reduce.
  • Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" }) formats rupees correctly.

Exercise

Add a monthly budget to the tracker. Store a budget number (default ₹20,000) in the provider alongside the expenses, with a budgetChanged action. Show an input to change it, display "Remaining: ₹X" under the total for the selected month, and colour it red when the remaining amount is below zero.

Show solution
React JSX
// 1. Reducer: keep expenses and budget in one state object
function trackerReducer(state, action) {
  switch (action.type) {
    case "added":
      return { ...state, expenses: [action.expense, ...state.expenses] };
    case "deleted":
      return { ...state, expenses: state.expenses.filter((e) => e.id !== action.id) };
    case "budgetChanged":
      return { ...state, budget: action.budget };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

// 2. Provider: initial state and context value
const [state, dispatch] = useReducer(trackerReducer, undefined, () => ({ expenses: loadExpenses(), budget: 20000 }));
// <ExpensesContext value={{ expenses: state.expenses, budget: state.budget, dispatch }}>

// 3. Summary: show the remaining amount
function Budget({ spent }) {
  const { budget, dispatch } = useExpenses();
  const remaining = budget - spent;
  return (
    <p>
      Budget:{" "}
      <input type="number" value={budget} onChange={(e) => dispatch({ type: "budgetChanged", budget: Number(e.target.value) })} />{" "}
      <span style={{ color: remaining < 0 ? "crimson" : "green" }}>Remaining: {rupees.format(remaining)}</span>
    </p>
  );
}
// Render <Budget spent={total} /> inside Summary, below the total.

The budget lives in the same reducer state as the expenses, so it is saved and shared the same way. The remaining amount is derived during render from the budget and the month's total, so it updates instantly when either changes. Remember to save and load the whole { expenses, budget } object in the storage effect.

Quick Quiz

1. Why are dates stored as YYYY-MM-DD strings?

2. Why does the app put the reducer in a context provider?

3. Why does the form get key={state.attempt}?

4. How are the category totals produced?

Learn React with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call