Build a Spend Tracker with React

Build a React expense tracker with interactive pie and bar charts.

Introduction

30 Second Summary

Tracking where your money goes is one of those things everyone knows they should do but few actually stick with. The right tool makes all the difference.

In this project, you will build a personal spend tracker app using React and Recharts. You will create a form to log expenses, display them in a transaction list, and visualize your spending patterns through interactive charts.

What You'll Build

A browser-based spend tracker that lets you log expenses, view them in a transaction list, and explore your spending habits through interactive pie and bar charts.

By the end of this project, you'll have:

  • An expense entry form with amount, category, and date fields that saves expenses to a transaction list.
  • Interactive pie and bar charts that visualize your spending by category and over time using Recharts.
  • Persistent data storage using localStorage so your expenses survive page refreshes.
  • A monthly budget tracker with a dynamic progress bar that warns you when spending gets too high (Secret Mission).

Are there any prerequisites?

This project assumes you have basic React knowledge, including components, props, state, and hooks. You should also have Node.js and Cursor installed on your machine.

Before We Start

Before We Start...

Scaffold the React Project and Install Dependencies

Your spend tracker is a React app that visualizes spending data with interactive charts. Before you can start building, you need a project scaffold with a dev server and the right dependencies installed.

You'll use Vite to create the React project and install Recharts for charting. By the end of this step, your dev server will be running with a clean starter app.

In this step, get ready to:
  • Scaffold a new React project with Vite.
  • Install Recharts and its peer dependency.
  • Clean up the starter files and start the dev server.
Create a new Vite + React project
  • Press Ctrl+` to open the integrated terminal in Cursor.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your React project by running this command:
npm create vite@latest spend-tracker -- --template react

What does this command do?

  • npm create vite@latest downloads and runs Vite's project scaffolding tool.
  • spend-tracker sets the name of your project folder.
  • -- --template react tells Vite to use the React JavaScript template, skipping interactive prompts.

Seeing a prompt to install create-vite?

Your terminal may ask Need to install the following packages: create-vite@latest. Ok to proceed? Type y and press Enter to continue.

  • You should see a Scaffolding project in message followed by a Done confirmation.
  • Move into your new project folder by running this command:
cd spend-tracker

Seeing an error after the scaffold command?

  • Make sure you typed the command exactly, including the two separate -- flags before --template react.
  • If you see npm: command not found, Node.js is not installed. Revisit your Node.js installation before continuing.
  • help me troubleshoot my Vite scaffold error
Install Recharts

Recharts is the charting library you'll use to render pie charts and bar charts from your spending data. It uses composable React components, so building charts feels just like building any other part of your UI.

  • Install Recharts and its peer dependency by running this command:
npm install recharts react-is

Why react-is?

Since Recharts version 3, react-is is a required peer dependency. Without it, Recharts throws errors at runtime. Installing them together ensures everything works from the start.

  • You should see added followed by a package count in your terminal output. This confirms all dependencies (including Vite's base packages) are installed.

Install command failed?

  • Make sure you are inside the spend-tracker folder. Run pwd and check the path ends with /spend-tracker.
  • If you see permission errors, try running the command again. Temporary network or file-system locks can cause intermittent failures.
  • help me fix my npm install error
Clean up the starter files and start the dev server

Vite generates demo content to show you the scaffold worked. You need to strip that out and replace it with a clean starting point for your spend tracker.

  • In Cursor's Explorer sidebar on the left, expand the src folder inside spend-tracker.
  • Click on App.jsx to open it.
  • Select all the existing code with Cmd+A (macOS) or Ctrl+A (Windows) and replace it with this code:
import './App.css';

function App() {
  return (
    <div className="app">
      <h1>Spend Tracker</h1>
    </div>
  );
}

export default App;

What does this code do?

  • This creates a minimal React component that renders a single heading.
  • The className="app" on the wrapping div gives you a hook for styling later.
  • The import './App.css' line connects your component to its stylesheet, which you'll populate in a later step.
  • Save App.jsx with Cmd+S (macOS) or Ctrl+S (Windows).

Now clear the remaining starter files so Vite's demo styles don't interfere with your app.

  • Open src/App.css in the sidebar.
  • Select all the text with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
  • Save the empty file.
  • Open src/index.css and delete all its contents the same way.
  • Save the empty file.
  • Open src/main.jsx and delete the line that says import './index.css';.
  • Save main.jsx.
  • Start the development server by running this command:
npm run dev
  • Your terminal displays a URL (usually http://localhost:5173). Hold Cmd (macOS) or Ctrl (Windows) and click the URL to open it in your browser.
  • You should see a white page with Spend Tracker as a heading.

Don't see the heading?

  • Make sure you saved all three files: App.jsx, App.css, and main.jsx. Unsaved changes won't show in the browser.
  • If you still see Vite's default demo page with spinning logos, double-check that you replaced the entire contents of App.jsx and not just part of it.
  • Check your terminal for red error messages. A typo in App.jsx (like a missing closing tag) shows up there.
  • help me troubleshoot why my Spend Tracker heading isn't showing

Awesome, I've got everything!

Double-check that you've saved all your files before moving on.

I'd like to double check the full code

Here is what your App.jsx should look like at this point.

import './App.css';

function App() {
  return (
    <div className="app">
      <h1>Spend Tracker</h1>
    </div>
  );
}

export default App;

Your main.jsx should look like this after removing the index.css import.

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

Both App.css and index.css should be empty.

Your project is scaffolded, your dependencies are installed, and your dev server is running. Next up, you'll build the expense form that lets you log your spending.

Build the Expense Form

Your project is scaffolded and ready to go. The next step is building the first interactive piece of your spend tracker: the expense entry form.

You'll create a dedicated form component with fields for amount, category, and date. Then you'll wire it into your app so that each submission gets stored in React state.

In this step, get ready to:
  • Create the ExpenseForm component with controlled inputs.
  • Add form submission logic with validation.
  • Wire ExpenseForm into App and test it.
Create the ExpenseForm component

React forms work best with controlled inputs, where each field's value is tied to a piece of state. When the user types, the state updates, and the input re-renders with the new value.

  • Create the src/components directory and the ExpenseForm.jsx file by running these commands in your terminal:
mkdir src/components
touch src/components/ExpenseForm.jsx
  • Confirm the file exists by running:
ls src/components

You should see ExpenseForm.jsx in the output.

  • In Cursor's Explorer sidebar on the left, expand the src folder, then the components folder.
  • Click ExpenseForm.jsx to open it, then paste the following code:
import { useState } from 'react';

// Category options for the expense dropdown
const CATEGORIES = ['Food', 'Transport', 'Entertainment', 'Shopping', 'Bills', 'Other'];

function ExpenseForm({ onAddExpense }) {
  // Controlled state for each form field
  const [amount, setAmount] = useState('');
  const [category, setCategory] = useState('Food');
  const [date, setDate] = useState(new Date().toISOString().split('T')[0]);

  return (
    <form className="expense-form">
      <input
        type="number"
        placeholder="Amount"
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
        min="0.01"
        step="0.01"
        required
      />
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        {CATEGORIES.map((cat) => (
          <option key={cat} value={cat}>
            {cat}
          </option>
        ))}
      </select>
      <input
        type="date"
        value={date}
        onChange={(e) => setDate(e.target.value)}
        required
      />
      <button type="submit">Add Expense</button>
    </form>
  );
}

export default ExpenseForm;

What does this code do?

  • CATEGORIES defines the six spending categories your dropdown will offer.
  • Three useState hooks create controlled state for the amount, category, and date fields. Each input's value is driven by state, and each onChange updates that state when the user types.
  • The date field initializes to today's date using new Date().toISOString().split('T')[0], which extracts the YYYY-MM-DD format that HTML date inputs expect.
  • Save ExpenseForm.jsx.

You won't see changes in the browser yet. The component isn't connected to your app. The next substep adds submission logic, then you'll wire everything together.

Handle form submission

The form renders, but clicking the button does nothing useful yet. You need a submit handler that collects the field values, builds an expense object, sends it to the parent component, and resets the form.

  • In ExpenseForm.jsx, find the line that says const [date, setDate] = useState(...).
  • Add the following handleSubmit function directly below it, before the return statement:
  const handleSubmit = (e) => {
    // Prevent the browser from refreshing the page
    e.preventDefault();
    // Skip submission if amount is empty or not positive
    if (!amount || parseFloat(amount) <= 0) return;

    // Build the expense object
    const expense = {
      id: Date.now(),
      amount: parseFloat(amount),
      category,
      date,
    };

    // Send the expense to the parent component
    onAddExpense(expense);

    // Reset form fields to defaults
    setAmount('');
    setCategory('Food');
    setDate(new Date().toISOString().split('T')[0]);
  };

What does this code do?

  • e.preventDefault() stops the browser from doing a full page refresh when the form submits.
  • The guard clause checks that the amount is a positive number before creating the expense.
  • Date.now() generates a unique id for each expense. This is a quick way to create unique identifiers without pulling in a library.
  • onAddExpense(expense) passes the new expense object up to the parent component through the callback prop.
  • After submission, all three fields reset to their defaults so the form is ready for the next entry.

Now you need to tell the form to call this function when the user clicks the submit button.

  • In ExpenseForm.jsx, scroll down to the return statement and find this line:
    <form className="expense-form">
  • Add onSubmit={handleSubmit} to the opening <form> tag, so the line now looks like this:
    <form className="expense-form" onSubmit={handleSubmit}>
  • Save ExpenseForm.jsx.

You won't see changes in the browser yet. In the next substep, you'll connect this component to your app and test it.

Wire ExpenseForm into App

The form component is complete, but your app doesn't know it exists. You need to import it, set up an expenses array in state, and pass a callback that the form can use to add new entries.

  • Open src/App.jsx and replace its entire contents with the following code:
import { useState } from 'react';
import ExpenseForm from './components/ExpenseForm';
import './App.css';

function App() {
  // Array to hold all expense objects
  const [expenses, setExpenses] = useState([]);

  // Append a new expense to the array
  const handleAddExpense = (expense) => {
    setExpenses((prev) => [...prev, expense]);
  };

  return (
    <div className="app">
      <h1>Spend Tracker</h1>
      <ExpenseForm onAddExpense={handleAddExpense} />
    </div>
  );
}

export default App;

What does this code do?

  • useState([]) initializes an empty array to store all the expenses the user adds.
  • handleAddExpense uses the spread operator to create a new array with the existing expenses plus the new one. This is how React detects state changes and re-renders.
  • The <ExpenseForm> component receives handleAddExpense as its onAddExpense prop. This is the callback pattern: the child calls a function defined in the parent to pass data upward.
  • Save App.jsx.
  • Check your browser. You should see the Spend Tracker heading with a form below it containing an amount input, a category dropdown, a date picker, and an Add Expense button.
  • Enter a number in the amount field, pick a category, and click Add Expense.

The form fields should reset to their defaults after submission. The amount clears, the category goes back to Food, and the date resets to today. That reset confirms the submit handler is working correctly.

Form not showing up?

  • Make sure your dev server is still running. If you closed the terminal, run npm run dev from inside the spend-tracker folder.
  • Check that the import path in App.jsx is exactly ./components/ExpenseForm. A missing ./ prefix or a typo in the folder name will cause an import error.
  • Confirm that ExpenseForm.jsx has export default ExpenseForm at the bottom of the file.

Why isn't my ExpenseForm rendering in the browser?

Form fields not resetting after submit?

  • Check that the <form> tag in ExpenseForm.jsx includes onSubmit={handleSubmit}. Without it, the browser handles the form submit with a page refresh instead of calling your function.
  • Verify that handleSubmit is defined between the state declarations and the return statement, not outside the component function.

Why aren't my form fields resetting after I click Add Expense?

Awesome, I've got everything!

Double check you've saved both ExpenseForm.jsx and App.jsx.

I'd like to double check the full code

Here is the full code for both files at the end of this step.

import { useState } from 'react';

// Category options for the expense dropdown
const CATEGORIES = ['Food', 'Transport', 'Entertainment', 'Shopping', 'Bills', 'Other'];

function ExpenseForm({ onAddExpense }) {
  // Controlled state for each form field
  const [amount, setAmount] = useState('');
  const [category, setCategory] = useState('Food');
  const [date, setDate] = useState(new Date().toISOString().split('T')[0]);

  const handleSubmit = (e) => {
    // Prevent the browser from refreshing the page
    e.preventDefault();
    // Skip submission if amount is empty or not positive
    if (!amount || parseFloat(amount) <= 0) return;

    // Build the expense object
    const expense = {
      id: Date.now(),
      amount: parseFloat(amount),
      category,
      date,
    };

    // Send the expense to the parent component
    onAddExpense(expense);

    // Reset form fields to defaults
    setAmount('');
    setCategory('Food');
    setDate(new Date().toISOString().split('T')[0]);
  };

  return (
    <form className="expense-form" onSubmit={handleSubmit}>
      <input
        type="number"
        placeholder="Amount"
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
        min="0.01"
        step="0.01"
        required
      />
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        {CATEGORIES.map((cat) => (
          <option key={cat} value={cat}>
            {cat}
          </option>
        ))}
      </select>
      <input
        type="date"
        value={date}
        onChange={(e) => setDate(e.target.value)}
        required
      />
      <button type="submit">Add Expense</button>
    </form>
  );
}

export default ExpenseForm;
import { useState } from 'react';
import ExpenseForm from './components/ExpenseForm';
import './App.css';

function App() {
  // Array to hold all expense objects
  const [expenses, setExpenses] = useState([]);

  // Append a new expense to the array
  const handleAddExpense = (expense) => {
    setExpenses((prev) => [...prev, expense]);
  };

  return (
    <div className="app">
      <h1>Spend Tracker</h1>
      <ExpenseForm onAddExpense={handleAddExpense} />
    </div>
  );
}

export default App;

Your expense form is live and accepting input. Next up, you'll build a transaction list so you can actually see the expenses you've added.

Display the Transaction List

Your expense form can accept new entries, but there is no way to see what you have added. Without a visible list, your expenses disappear into state with no feedback.

In this step, you will build a transaction list component that renders every expense on the page. You will also add delete functionality so users can remove entries they no longer need.

In this step, get ready to:
  • Create the ExpenseList component.
  • Add delete functionality to remove expenses.
  • Wire the list into the app and test it end to end.
Create the ExpenseList component

Each expense needs to display its category, amount, and date. The component also needs to handle an empty state for when no expenses have been added yet.

  • In the Explorer sidebar in Cursor, right-click the src/components folder.
  • Click New File and name it ExpenseList.jsx.
  • Copy the following code into ExpenseList.jsx:
function ExpenseList({ expenses, onDeleteExpense }) {
  // Show a placeholder message when there are no expenses
  if (expenses.length === 0) {
    return <div className="expense-list empty">No expenses yet. Add one above!</div>;
  }

  return (
    <div className="expense-list">
      <h2>Transactions</h2>
      <ul>
        {expenses.map((expense) => (
          <li key={expense.id} className="expense-item">
            {/* Left side: category and date */}
            <div className="expense-info">
              <span className="expense-category">{expense.category}</span>
              <span className="expense-date">{expense.date}</span>
            </div>
            {/* Right side: amount and delete button */}
            <div className="expense-actions">
              <span className="expense-amount">
                ${expense.amount.toFixed(2)}
              </span>
              <button
                className="delete-btn"
                onClick={() => onDeleteExpense(expense.id)}
              >
                Delete
              </button>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default ExpenseList;

What does this code do?

  • The component accepts two props: expenses (the array of expense objects) and onDeleteExpense (a callback to remove one).
  • If the array is empty, it returns a placeholder message instead of an empty list.
  • expenses.map() loops through the array and renders each expense as an <li> element with the category, date, formatted amount, and a delete button.
  • toFixed(2) ensures the amount always shows two decimal places, like $12.50 instead of $12.5.
  • Save ExpenseList.jsx.

You will not see a change in the browser yet. The component needs to be imported and rendered in App.jsx first.

Add delete functionality in App.jsx

The delete button in ExpenseList calls an onDeleteExpense prop, but that function does not exist yet. You need to create it in App.jsx where the expenses state lives.

  • In App.jsx, find the handleAddExpense function.
  • Add the following function directly below it:
  // Remove an expense from the array by its unique id
  const handleDeleteExpense = (id) => {
    setExpenses((prev) => prev.filter((expense) => expense.id !== id));
  };

What does this code do?

filter() creates a new array that includes every expense except the one whose id matches the one being deleted. React re-renders the list automatically because setExpenses updates the state with this new array.

Wire ExpenseList into App and test

With the component built and the delete handler ready, it is time to connect everything. You need to import ExpenseList and render it below the form.

  • At the top of App.jsx, find your existing import for ExpenseForm.
  • Add this import directly below it:
import ExpenseList from './components/ExpenseList';
  • In the return statement, find the <ExpenseForm /> line.
  • Replace everything below <ExpenseForm /> (but still inside the closing </div>) with a main-content wrapper that renders the ExpenseList:
      {/* Main content area for the transaction list */}
      <div className="main-content">
        <ExpenseList expenses={expenses} onDeleteExpense={handleDeleteExpense} />
      </div>

What does this code do?

  • The main-content div wraps the list (and eventually the charts) in a layout container.
  • expenses={expenses} passes the full array down so the list always reflects the current state.
  • onDeleteExpense={handleDeleteExpense} passes the handler so each row's delete button can remove its expense.
  • Save App.jsx and check your browser.
  • You should see the text "No expenses yet. Add one above!" below the form.
  • Use the form to add a test expense. Enter 25 as the amount, pick any category, and click Add Expense.
  • The expense should appear in the transaction list showing the category, date, and $25.00.
  • Click Delete next to the expense.
  • The expense should disappear and the empty state message should return.

Expenses not showing up?

  • Make sure you saved both ExpenseList.jsx and App.jsx. Unsaved files will not be picked up by the dev server.
  • Check that your import path is exactly './components/ExpenseList'. A typo in the path causes a module-not-found error in the terminal.
  • Confirm you are passing both expenses and onDeleteExpense as props on the <ExpenseList /> tag. Missing props cause silent rendering failures.

Help me debug why my ExpenseList is not rendering expenses.

Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

I'd like to double check the full code

Here is the full ExpenseList.jsx:

function ExpenseList({ expenses, onDeleteExpense }) {
  if (expenses.length === 0) {
    return <div className="expense-list empty">No expenses yet. Add one above!</div>;
  }

  return (
    <div className="expense-list">
      <h2>Transactions</h2>
      <ul>
        {expenses.map((expense) => (
          <li key={expense.id} className="expense-item">
            <div className="expense-info">
              <span className="expense-category">{expense.category}</span>
              <span className="expense-date">{expense.date}</span>
            </div>
            <div className="expense-actions">
              <span className="expense-amount">
                ${expense.amount.toFixed(2)}
              </span>
              <button
                className="delete-btn"
                onClick={() => onDeleteExpense(expense.id)}
              >
                Delete
              </button>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default ExpenseList;

And here is the full App.jsx at this point:

import { useState } from 'react';
import ExpenseForm from './components/ExpenseForm';
import ExpenseList from './components/ExpenseList';
import './App.css';

function App() {
  const [expenses, setExpenses] = useState([]);

  const handleAddExpense = (expense) => {
    setExpenses((prev) => [...prev, expense]);
  };

  const handleDeleteExpense = (id) => {
    setExpenses((prev) => prev.filter((expense) => expense.id !== id));
  };

  return (
    <div className="app">
      <h1>Spend Tracker</h1>
      <ExpenseForm onAddExpense={handleAddExpense} />
      <div className="main-content">
        <ExpenseList expenses={expenses} onDeleteExpense={handleDeleteExpense} />
      </div>
    </div>
  );
}

export default App;

Your transaction list is working. You can add expenses and remove them. Next up, you will turn that raw data into interactive pie and bar charts so your spending patterns are instantly visible.

Add the Spending Charts

Your transaction list is working. You can add expenses and delete them from the list.

Numbers in a list are useful, but patterns in your spending are hard to spot by scrolling through rows. In this step, you will turn that raw data into two interactive charts: a pie chart that breaks down spending by category and a bar chart that shows spending over time.

In this step, get ready to:
  • Create a SpendingCharts component that transforms expense data into chart-ready datasets.
  • Build an interactive pie chart showing spending by category.
  • Build a bar chart showing daily spending trends and wire everything into the app.
Create the SpendingCharts component

Recharts expects data in a specific shape. A pie chart needs an array of objects with name and value fields. A bar chart needs objects with date and amount fields.

Your raw expenses array stores individual transactions. The first job of this component is to aggregate those transactions into the totals that charts can display.

  • Create a new file at src/components/SpendingCharts.jsx.
  • Paste the following code into the file:
import {
  PieChart,
  Pie,
  Cell,
  Tooltip,
  Legend,
  ResponsiveContainer,
  BarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
} from 'recharts';

// Colors for pie chart slices
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8', '#82CA9D'];

function SpendingCharts({ expenses }) {
  // Show a message when there's nothing to chart
  if (expenses.length === 0) {
    return <div className="charts-container empty">Add expenses to see your charts!</div>;
  }

  // Aggregate spending totals per category
  const categoryData = expenses.reduce((acc, expense) => {
    const existing = acc.find((item) => item.name === expense.category);
    if (existing) {
      existing.value += expense.amount;
    } else {
      acc.push({ name: expense.category, value: expense.amount });
    }
    return acc;
  }, []);

  // Aggregate spending totals per date, sorted chronologically
  const dailyData = expenses
    .reduce((acc, expense) => {
      const existing = acc.find((item) => item.date === expense.date);
      if (existing) {
        existing.amount += expense.amount;
      } else {
        acc.push({ date: expense.date, amount: expense.amount });
      }
      return acc;
    }, [])
    .sort((a, b) => new Date(a.date) - new Date(b.date));

  return (
    <div className="charts-container">
    </div>
  );
}

export default SpendingCharts;

What does this code do?

  • The imports pull in every Recharts component you will need for both charts. Importing them all upfront keeps the file organized.
  • COLORS is a six-color palette. Each pie slice gets a different color from this array.
  • categoryData uses .reduce() to loop through every expense and sum up the total amount for each category.
  • dailyData does the same aggregation but groups by date instead of category. The .sort() at the end arranges dates from earliest to latest so the bar chart reads left to right.
  • Save SpendingCharts.jsx.

You will not see a change in the browser yet. The next chunks add the chart visuals to the return statement.

Build the pie chart for category breakdown

Recharts uses a composable component pattern. You nest chart elements like Pie, Tooltip, and Legend inside a parent PieChart component, just like you nest HTML elements.

  • In SpendingCharts.jsx, find the empty <div className="charts-container"> in the return statement.
  • Paste the following code inside that div:
      <div className="chart">
        <h2>Spending by Category</h2>
        <ResponsiveContainer width="100%" height={300}>
          <PieChart>
            <Pie
              data={categoryData}
              cx="50%"
              cy="50%"
              labelLine={false}
              outerRadius={100}
              fill="#8884d8"
              dataKey="value"
              label={({ name, percent }) =>
                `${name} ${(percent * 100).toFixed(0)}%`
              }
            >
              {categoryData.map((entry, index) => (
                <Cell
                  key={`cell-${index}`}
                  fill={COLORS[index % COLORS.length]}
                />
              ))}
            </Pie>
            <Tooltip formatter={(value) => `$${value.toFixed(2)}`} />
            <Legend />
          </PieChart>
        </ResponsiveContainer>
      </div>

What does this code do?

  • ResponsiveContainer wraps the chart so it stretches to fill its parent width. You set the height to 300 pixels.
  • Pie receives categoryData and reads the value field from each object to size each slice.
  • The label prop takes a function that displays each category name and its percentage on the chart.
  • Cell components assign a unique color from the COLORS array to each pie slice.
  • Tooltip shows the dollar amount when you hover over a slice. Legend adds a color key below the chart.
  • Save SpendingCharts.jsx.

You still will not see a change in the browser. The component is not imported into the app yet. The next substep adds the bar chart, then you will wire everything in.

Build the bar chart for daily spending

The bar chart uses the same composable pattern as the pie chart. You nest axis, grid, and bar components inside a parent BarChart.

  • In SpendingCharts.jsx, find the closing </div> of the pie chart's chart div (the one right after </ResponsiveContainer>).
  • Paste the following code below it, still inside the charts-container div:
      <div className="chart">
        <h2>Daily Spending</h2>
        <ResponsiveContainer width="100%" height={300}>
          <BarChart data={dailyData}>
            <CartesianGrid strokeDasharray="3 3" />
            <XAxis dataKey="date" />
            <YAxis />
            <Tooltip formatter={(value) => `$${value.toFixed(2)}`} />
            <Bar dataKey="amount" fill="#0088FE" />
          </BarChart>
        </ResponsiveContainer>
      </div>

What does this code do?

  • CartesianGrid draws a light dashed grid behind the bars to help you read values at a glance.
  • XAxis with dataKey="date" labels the horizontal axis with dates. YAxis adds a dollar scale on the left.
  • Bar with dataKey="amount" renders one bar per date, sized by the aggregated spending total for that day.
  • Save SpendingCharts.jsx.

Awesome, I've got everything!

Great. Double check you have saved SpendingCharts.jsx before moving on.

I'd like to double check the full code

import {
  PieChart,
  Pie,
  Cell,
  Tooltip,
  Legend,
  ResponsiveContainer,
  BarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
} from 'recharts';

const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8', '#82CA9D'];

function SpendingCharts({ expenses }) {
  if (expenses.length === 0) {
    return <div className="charts-container empty">Add expenses to see your charts!</div>;
  }

  const categoryData = expenses.reduce((acc, expense) => {
    const existing = acc.find((item) => item.name === expense.category);
    if (existing) {
      existing.value += expense.amount;
    } else {
      acc.push({ name: expense.category, value: expense.amount });
    }
    return acc;
  }, []);

  const dailyData = expenses
    .reduce((acc, expense) => {
      const existing = acc.find((item) => item.date === expense.date);
      if (existing) {
        existing.amount += expense.amount;
      } else {
        acc.push({ date: expense.date, amount: expense.amount });
      }
      return acc;
    }, [])
    .sort((a, b) => new Date(a.date) - new Date(b.date));

  return (
    <div className="charts-container">
      <div className="chart">
        <h2>Spending by Category</h2>
        <ResponsiveContainer width="100%" height={300}>
          <PieChart>
            <Pie
              data={categoryData}
              cx="50%"
              cy="50%"
              labelLine={false}
              outerRadius={100}
              fill="#8884d8"
              dataKey="value"
              label={({ name, percent }) =>
                `${name} ${(percent * 100).toFixed(0)}%`
              }
            >
              {categoryData.map((entry, index) => (
                <Cell
                  key={`cell-${index}`}
                  fill={COLORS[index % COLORS.length]}
                />
              ))}
            </Pie>
            <Tooltip formatter={(value) => `$${value.toFixed(2)}`} />
            <Legend />
          </PieChart>
        </ResponsiveContainer>
      </div>
      <div className="chart">
        <h2>Daily Spending</h2>
        <ResponsiveContainer width="100%" height={300}>
          <BarChart data={dailyData}>
            <CartesianGrid strokeDasharray="3 3" />
            <XAxis dataKey="date" />
            <YAxis />
            <Tooltip formatter={(value) => `$${value.toFixed(2)}`} />
            <Bar dataKey="amount" fill="#0088FE" />
          </BarChart>
        </ResponsiveContainer>
      </div>
    </div>
  );
}

export default SpendingCharts;

Now you need to import this component into App.jsx so the charts actually render in your app.

  • Open src/App.jsx.
  • Find your existing import lines at the top of the file. Add this import below the ExpenseList import:
import SpendingCharts from './components/SpendingCharts';
  • Scroll down to the return statement. Find the line with <ExpenseList expenses={expenses} onDeleteExpense={handleDeleteExpense} />.
  • Add the following line directly below it, still inside the main-content div:
        <SpendingCharts expenses={expenses} />

What's happening here?

You pass the same expenses state array to SpendingCharts that you already pass to ExpenseList. When you add or delete an expense, React re-renders both components with the updated data. The charts update automatically.

  • Save App.jsx and check your browser.
  • You should see the message "Add expenses to see your charts!" because you have no expenses yet.
  • Use the form to add 3 or 4 test expenses across different categories and dates.
  • You should see a pie chart with colored slices and percentage labels, plus a bar chart with one bar per unique date.
  • Hover over a pie slice or bar to see the dollar amount tooltip.

Charts not showing up?

  • Check your terminal for errors. A common cause is a missing import in SpendingCharts.jsx. Make sure all 12 Recharts components are listed in the import block.
  • If you see "Add expenses to see your charts!" but nothing after adding expenses, make sure you are passing expenses={expenses} as a prop on the <SpendingCharts> tag in App.jsx.
  • If you see a blank white area where the charts should be, make sure the ResponsiveContainer has both width="100%" and height={300} set. Without an explicit height, the charts collapse to zero pixels.
  • Still stuck? Help me debug why my Recharts pie or bar chart is not rendering in my React app.

Awesome, I've got everything!

Great. Double check you have saved App.jsx before moving on.

I'd like to double check the full code

import { useState } from 'react';
import ExpenseForm from './components/ExpenseForm';
import ExpenseList from './components/ExpenseList';
import SpendingCharts from './components/SpendingCharts';
import './App.css';

function App() {
  const [expenses, setExpenses] = useState([]);

  const handleAddExpense = (expense) => {
    setExpenses((prev) => [...prev, expense]);
  };

  const handleDeleteExpense = (id) => {
    setExpenses((prev) => prev.filter((expense) => expense.id !== id));
  };

  return (
    <div className="app">
      <h1>Spend Tracker</h1>
      <ExpenseForm onAddExpense={handleAddExpense} />
      <div className="main-content">
        <ExpenseList expenses={expenses} onDeleteExpense={handleDeleteExpense} />
        <SpendingCharts expenses={expenses} />
      </div>
    </div>
  );
}

export default App;

Why Recharts over other charting libraries?

Recharts uses composable React components instead of a configuration-object API. You build charts by nesting <Pie>, <Tooltip>, and <Legend> the same way you nest <div> and <span> in JSX. This makes the patterns feel familiar to any React developer.

Your charts are alive. Try adding a few more expenses and watch the pie slices and bar heights adjust in real time. Next up, you will make your data stick around by saving it to localStorage so expenses survive a page refresh.

Persist Data with localStorage

Your charts are alive. Add a few expenses and the pie chart breaks down your spending by category while the bar chart plots your daily totals.

There is one problem though. Refresh the page and everything disappears. React state lives in memory, so it resets every time the browser reloads. In this step, you will wire up localStorage so your expenses survive page refreshes.

In this step, get ready to:
  • Load saved expenses from localStorage when the app starts.
  • Save expenses to localStorage whenever the data changes.
  • Test that expenses survive a full page refresh.
Load expenses from localStorage on mount

localStorage is a browser API that stores key-value pairs as strings. Data saved to localStorage persists even after you close the tab or refresh the page.

What is localStorage?

Every browser gives each website its own localStorage space. You save data with localStorage.setItem() and retrieve it with localStorage.getItem(). Since it only stores strings, you need JSON.stringify() and JSON.parse() to convert arrays and objects back and forth.

Right now, your expenses state always starts as an empty array. You will change it to check localStorage first and load any previously saved data.

  • In App.jsx, find the import line at the top of the file:
import { useState } from 'react';
  • Add useEffect to the import so the line looks like this:
import { useState, useEffect } from 'react';
  • Next, find the useState line inside the App function:
const [expenses, setExpenses] = useState([]);
  • Replace that line with a lazy initializer that reads from localStorage:
// Load saved expenses from localStorage, or start with an empty array
const [expenses, setExpenses] = useState(() => {
  const saved = localStorage.getItem('expenses');
  return saved ? JSON.parse(saved) : [];
});

What is a lazy initializer?

Passing a function to useState instead of a value means React only calls that function once, when the component first mounts.

This avoids reading from localStorage on every re-render. The function checks if there is a saved expenses key in localStorage. If so, it parses the JSON string back into an array. If not, it starts with an empty array.

Save expenses to localStorage on change

Loading from localStorage is only half the picture. You also need to save the data whenever the expenses array changes.

  • In App.jsx, add this useEffect hook directly below the useState block you just updated:
// Sync expenses to localStorage whenever the array changes
useEffect(() => {
  localStorage.setItem('expenses', JSON.stringify(expenses));
}, [expenses]);

What does this code do?

  • useEffect runs a side effect after React finishes rendering.
  • The [expenses] dependency array tells React to re-run this effect only when expenses changes. Every time you add or delete an expense, the updated array is written to localStorage.
  • JSON.stringify() converts the array into a string because localStorage can only store strings.
  • Save App.jsx.

You will not see a visible change yet. The next substep tests that persistence is working.

Awesome, I've got everything!

Double check you have saved App.jsx before moving on.

I'd like to double check the full code

Here is the complete App.jsx at this point.

import { useState, useEffect } from 'react';
import ExpenseForm from './components/ExpenseForm';
import ExpenseList from './components/ExpenseList';
import SpendingCharts from './components/SpendingCharts';
import './App.css';

function App() {
  const [expenses, setExpenses] = useState(() => {
    const saved = localStorage.getItem('expenses');
    return saved ? JSON.parse(saved) : [];
  });

  useEffect(() => {
    localStorage.setItem('expenses', JSON.stringify(expenses));
  }, [expenses]);

  const handleAddExpense = (expense) => {
    setExpenses((prev) => [...prev, expense]);
  };

  const handleDeleteExpense = (id) => {
    setExpenses((prev) => prev.filter((expense) => expense.id !== id));
  };

  return (
    <div className="app">
      <h1>Spend Tracker</h1>
      <ExpenseForm onAddExpense={handleAddExpense} />
      <div className="main-content">
        <ExpenseList expenses={expenses} onDeleteExpense={handleDeleteExpense} />
        <SpendingCharts expenses={expenses} />
      </div>
    </div>
  );
}

export default App;
Test persistence

Time to prove that your data actually sticks around.

  • Add two or three expenses through the form using different categories and dates.
  • Confirm the expenses appear in the transaction list and the charts update.
  • Refresh your browser page with Cmd+R (macOS) or Ctrl+R (Windows).

Your expenses and charts should reappear exactly as you left them. The transaction list shows the same entries. The charts display the same breakdowns.

  • Delete one of the expenses by clicking its Delete button.
  • Refresh the page again.
  • Confirm that the deleted expense is gone and the remaining expenses still appear.

Expenses disappearing after refresh?

  • Make sure the useEffect has [expenses] as the dependency array. Without it, the effect does not re-run when expenses change.
  • Check that your lazy initializer uses JSON.parse() to convert the stored string back into an array. A missing JSON.parse() means you get a raw string instead of an array.
  • Verify that the key name 'expenses' matches in both getItem() and setItem(). A typo in either one means the data is saved under one key but loaded from a different one.

Why are my expenses not persisting after a page refresh?

Pro tip: inspect localStorage in DevTools

Open your browser DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows). Go to the Application tab, then expand Local Storage in the left sidebar. You can see the raw JSON string stored under the expenses key and verify it matches your data.

Your spend tracker now remembers everything. Next up, you will style the app so it looks as polished as it works.

Style the App

Your expenses now persist across page refreshes thanks to localStorage. The app is fully functional, but it still looks like raw, unstyled HTML.

In this step, you will add CSS styles to transform the app into a polished spend tracker. Clean typography, a well-spaced layout, and styled components make the difference between a prototype and an app you would actually want to use.

In this step, get ready to:
  • Style the app layout and typography.
  • Style the expense form and transaction list.
  • Style the charts section and test the full app end to end.
Add layout and typography styles

Every browser ships with its own default margins, padding, and font sizes. A CSS reset removes those defaults so your styles start from a consistent baseline across all browsers.

  • Open src/App.css and replace any existing content with the following styles:
/* Remove default browser margins and padding */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Set the base font and background color */
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background-color: #f5f5f5;
  color: #333;
}

/* Center the app with a max-width container */
.app {
  max-width: 900px;
  margin: 0 auto;
  padding: 2rem 1rem;
}

/* Style the main heading */
.app h1 {
  text-align: center;
  margin-bottom: 1.5rem;
  font-size: 2rem;
  color: #1a1a2e;
}

What does this code do?

  • The * selector resets every element's margin, padding, and box-sizing. box-sizing: border-box makes padding and borders count toward an element's total width, which prevents layout surprises.
  • The body rule sets a system font stack that uses the operating system's native font, plus a light gray background.
  • The .app class constrains the content to 900px wide and centers it horizontally with margin: 0 auto.
  • Save src/App.css.
  • Check your browser. You should see the Spend Tracker heading centered on a light gray background with comfortable padding around it.

Don't see any style changes?

  • Make sure you saved src/App.css. Unsaved changes will not appear in the browser.
  • Confirm that App.jsx still has import './App.css' at the top. Without that import, React does not load the stylesheet.
  • Try a hard refresh (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows) to clear any cached styles.

Still stuck? Ask for help.

Style the form and transaction list

The form inputs and expense list are the parts of the app your users interact with most. Flexbox makes it easy to lay out the form inputs in a row and the expense items as tidy cards.

  • In src/App.css, add the following form styles below the existing code:
/* Lay out form inputs in a flexible row */
.expense-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 2rem;
  flex-wrap: wrap;
}

/* Style text and select inputs */
.expense-form input,
.expense-form select {
  padding: 0.6rem 0.8rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 0.95rem;
  flex: 1;
  min-width: 120px;
}

/* Style the submit button */
.expense-form button {
  padding: 0.6rem 1.2rem;
  background-color: #0088FE;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 0.95rem;
  cursor: pointer;
  transition: background-color 0.2s;
}

/* Darken the button on hover */
.expense-form button:hover {
  background-color: #0070d6;
}

What does this code do?

  • display: flex on .expense-form places all three inputs and the button in a horizontal row. flex-wrap: wrap allows them to stack on smaller screens.
  • flex: 1 on the inputs makes them share available space equally, while min-width: 120px prevents them from shrinking too small.
  • The transition property on the button creates a smooth color change when the user hovers over it.
  • Save src/App.css and check your browser.
  • Your form inputs should now sit in a neat row with rounded borders. The Add Expense button should be blue and darken when you hover over it.

Form inputs still stacked vertically?

  • Check that the class name in your CSS (.expense-form) matches the class name in ExpenseForm.jsx exactly. CSS class names are case-sensitive.
  • Make sure you pasted the new styles below the existing code, not inside an existing rule.

Need a hand? Ask for help.

  • Continue adding styles in src/App.css for the main content area and transaction list:
/* Stack the list and charts vertically */
.main-content {
  display: flex;
  flex-direction: column;
  gap: 2rem;
}

/* Style section headings */
.expense-list h2,
.charts-container h2 {
  margin-bottom: 0.75rem;
  font-size: 1.25rem;
  color: #1a1a2e;
}

/* Remove default list bullets */
.expense-list ul {
  list-style: none;
}

/* Each expense row is a white card */
.expense-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem 1rem;
  background: white;
  border-radius: 6px;
  margin-bottom: 0.5rem;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}

/* Group category and date on the left */
.expense-info {
  display: flex;
  gap: 1rem;
  align-items: center;
}

/* Highlight the category name in blue */
.expense-category {
  font-weight: 600;
  color: #0088FE;
}

/* Dim the date text */
.expense-date {
  color: #888;
  font-size: 0.85rem;
}

/* Group amount and delete button on the right */
.expense-actions {
  display: flex;
  gap: 1rem;
  align-items: center;
}

/* Make the amount stand out */
.expense-amount {
  font-weight: 600;
  font-size: 1.05rem;
}

/* Style the red delete button */
.delete-btn {
  padding: 0.3rem 0.6rem;
  background-color: #ff4d4f;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.8rem;
  transition: background-color 0.2s;
}

/* Darken the delete button on hover */
.delete-btn:hover {
  background-color: #d9363e;
}

What does this code do?

  • .main-content uses a column-direction flexbox to stack the transaction list and charts vertically with 2rem of space between them.
  • Each .expense-item becomes a white card with rounded corners and a subtle shadow. justify-content: space-between pushes the category info to the left and the amount to the right.
  • The category text is highlighted in blue (#0088FE) to make it easy to scan, while the date is dimmed to reduce visual noise.
  • The .delete-btn uses a red background to signal a destructive action.
  • Save src/App.css.
  • Add a test expense through the form. You should see it appear as a white card with the category name in blue on the left and the amount with a red Delete button on the right.

Expense items not showing as cards?

  • Check that the class names in your CSS match the class names in ExpenseList.jsx exactly. For example, .expense-item must match className="expense-item".
  • If you see the Transactions heading but no cards, make sure you have at least one expense added. The list only renders when the expenses array is not empty.

Still not working? Ask for help.

Style the charts section

The charts need their own container styles. Using flexbox with flex-wrap lets the pie and bar charts sit side by side on wider screens and stack on narrower ones.

  • Add the final styles to the bottom of src/App.css:
/* Center empty-state messages */
.expense-list.empty,
.charts-container.empty {
  text-align: center;
  padding: 2rem;
  color: #888;
  background: white;
  border-radius: 6px;
}

/* Lay out charts side by side, wrapping on small screens */
.charts-container {
  display: flex;
  flex-wrap: wrap;
  gap: 2rem;
}

/* Each chart sits in a white card */
.chart {
  flex: 1;
  min-width: 300px;
  background: white;
  padding: 1rem;
  border-radius: 8px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}

What does this code do?

  • The .empty rule styles the placeholder messages that appear when there are no expenses yet.
  • .charts-container uses flex-wrap: wrap so the two charts sit side by side when there is enough room and automatically stack when the screen gets narrow.
  • Each .chart has min-width: 300px to make sure charts never shrink below a readable size.
  • Save src/App.css.
  • Add a few expenses across different categories and dates. You should see the pie chart and bar chart appear in white card containers, sitting side by side.

Charts overlapping or not side by side?

  • Make sure .charts-container in your CSS matches className="charts-container" in SpendingCharts.jsx.
  • If the charts stack on your screen, your browser window might be narrower than 600px. The flex-wrap rule intentionally stacks them when space runs out. Try widening your browser window.

Still having layout issues? Ask for help.

Time for a full end-to-end test of your finished app.

  • Add 3-4 expenses across different categories (like Food, Transport, and Entertainment) and different dates.
  • Confirm the pie chart shows a colored slice for each category and the bar chart shows bars for each date.
  • Click Delete on one expense and confirm it disappears from the list and the charts update.
  • Refresh the page. All remaining expenses and charts should reappear exactly as you left them.

Awesome, I've got everything!

Double check you have saved src/App.css before moving on.

I'd like to double check the full code

Here is the complete src/App.css file. Compare it with yours to spot any differences.

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background-color: #f5f5f5;
  color: #333;
}

.app {
  max-width: 900px;
  margin: 0 auto;
  padding: 2rem 1rem;
}

.app h1 {
  text-align: center;
  margin-bottom: 1.5rem;
  font-size: 2rem;
  color: #1a1a2e;
}

.expense-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 2rem;
  flex-wrap: wrap;
}

.expense-form input,
.expense-form select {
  padding: 0.6rem 0.8rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 0.95rem;
  flex: 1;
  min-width: 120px;
}

.expense-form button {
  padding: 0.6rem 1.2rem;
  background-color: #0088FE;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 0.95rem;
  cursor: pointer;
  transition: background-color 0.2s;
}

.expense-form button:hover {
  background-color: #0070d6;
}

.main-content {
  display: flex;
  flex-direction: column;
  gap: 2rem;
}

.expense-list h2,
.charts-container h2 {
  margin-bottom: 0.75rem;
  font-size: 1.25rem;
  color: #1a1a2e;
}

.expense-list ul {
  list-style: none;
}

.expense-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem 1rem;
  background: white;
  border-radius: 6px;
  margin-bottom: 0.5rem;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}

.expense-info {
  display: flex;
  gap: 1rem;
  align-items: center;
}

.expense-category {
  font-weight: 600;
  color: #0088FE;
}

.expense-date {
  color: #888;
  font-size: 0.85rem;
}

.expense-actions {
  display: flex;
  gap: 1rem;
  align-items: center;
}

.expense-amount {
  font-weight: 600;
  font-size: 1.05rem;
}

.delete-btn {
  padding: 0.3rem 0.6rem;
  background-color: #ff4d4f;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.8rem;
  transition: background-color 0.2s;
}

.delete-btn:hover {
  background-color: #d9363e;
}

.expense-list.empty,
.charts-container.empty {
  text-align: center;
  padding: 2rem;
  color: #888;
  background: white;
  border-radius: 6px;
}

.charts-container {
  display: flex;
  flex-wrap: wrap;
  gap: 2rem;
}

.chart {
  flex: 1;
  min-width: 300px;
  background: white;
  padding: 1rem;
  border-radius: 8px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}

Secret mission

Add a Monthly Budget Tracker

Your spend tracker logs expenses and shows charts. But how do you know if you're staying within your monthly budget? Add a budget limit feature with a dynamic progress bar that changes color as you approach your spending cap.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The spend-tracker project directory (all source files and node_modules).
  • Vite dev server (running process in your terminal).
  • localStorage data (the expenses key stored in your browser).

Keep everything running

No action needed. Choose this if you are still actively building or want to extend the app with the Secret Mission.

  • Your Vite dev server stays running in your terminal. You can access the app at the URL shown.
  • Your expense data persists in localStorage automatically.

Pause - I'll come back to this later

Shut down the running dev server to free up memory. Your project files and localStorage data will still be there when you come back.

  • Go to the terminal where the Vite dev server is running and press Ctrl + C to stop it.
  • Your project files stay in the spend-tracker directory. Your localStorage data stays in your browser.
  • To resume later, open the project in Cursor, run npm run dev, and you are back in business.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Stop the Vite dev server by pressing Ctrl + C in the terminal.
  • Delete the entire spend-tracker folder from your file system.
  • Clear localStorage data by opening your browser's developer tools, navigating to the Application tab, selecting Local Storage, and clicking Clear All.

Mission accomplished

That's a Wrap!

Nice work! You just built a fully functional personal spend tracker using React and Recharts.

Here's what you learned:

  • Built a data-driven React app with controlled forms, component composition, and state management using useState and useEffect.
  • Created interactive pie and bar charts with Recharts to visualize spending patterns by category and over time.
  • Persisted data across browser sessions using localStorage so expenses survive page refreshes.
  • Added a monthly budget tracker with a dynamic progress bar that changes color based on spending thresholds.

Ready to quiz yourself?