AI Expense Tracker with React
Build a deployed React expense tracker with charts and AI spending insights.
Introduction
30 Second Summary
Keeping track of where your money goes is one of those things everyone knows they should do but few stick with. Imagine an app that not only tracks your spending but uses AI to spot patterns and give you personalized saving tips.
In this project, you will build an AI-powered expense tracker using React. It visualizes your spending with interactive charts and uses Google Gemini to generate insights about your financial habits.
What You'll Build
A dark-themed React application with multi-page routing, interactive charts, AI-generated spending analysis, and live deployment on Vercel.
By the end of this project, you'll have:
- A React application with multi-page navigation, global state management using useContext and useReducer, and localStorage persistence.
- Interactive charts (pie and bar) built with Recharts that visualize spending by category and over time.
- An AI insights panel powered by Google Gemini that analyzes your spending patterns and suggests ways to save money.
- A polished, dark-themed UI built entirely with Tailwind CSS utility classes and deployed live on Vercel.
- Secret Mission: Add a budget goal system where the AI warns you when you are approaching or exceeding category limits.
Are there any prerequisites?
You should have basic JavaScript knowledge and be comfortable with variables, functions, and arrays. You will also need Node.js installed, VS Code ready, a Google account (for your free Gemini API key), and a GitHub account (for deployment).
Set Up the Project
Every web app starts with a foundation. Before you can build dashboards, charts, or AI features, you need a working project skeleton with all the right tools installed.
In this step, you will scaffold a React project using Vite, install the libraries you need (routing, charts, styling, AI), and configure Tailwind CSS for a dark-themed, utility-first design.
In this step, get ready to:
- Scaffold a React project and install all dependencies.
- Configure Tailwind CSS with a dark theme.
- Get your free Gemini API key and create the project folder structure.
Scaffold the React project and install dependencies
Vite is a modern build tool that creates React projects in seconds. You will use it to scaffold the app, then install the extra libraries for routing, charts, styling, and AI.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your React project by running this command:
npm create vite@latest expense-tracker -- --template react
What does this command do?
This uses Vite's project scaffolding tool to create a new folder called expense-tracker with a ready-to-go React setup. The --template react flag tells Vite you want a React project specifically.
- Move into your new project folder by running:
cd expense-tracker
- Install the project's base dependencies by running:
npm install
- Install React Router, Recharts, and the Google GenAI SDK by running:
npm install react-router-dom recharts react-is @google/genai
What are these libraries?
- react-router-dom handles multi-page navigation within your single-page app.
- recharts is a charting library built specifically for React. It needs react-is as a peer dependency.
- @google/genai is Google's official SDK for calling the Gemini API from JavaScript.
- Install Tailwind CSS and its peer dependencies as dev dependencies by running:
npm install -D tailwindcss@3 postcss autoprefixer
- Generate the Tailwind and PostCSS config files by running:
npx tailwindcss init -p
This creates two files: tailwind.config.js and postcss.config.js.
- Confirm the files exist by running:
ls
You should see tailwind.config.js and postcss.config.js alongside package.json, vite.config.js, and the src/ folder in the output.
Command not found or permission error?
- Make sure you ran cd expense-tracker before installing dependencies.
- If npm is not recognized, Node.js may not be installed or not in your PATH. Reinstall Node.js from the official website.
- help me fix my npm install issue
Configure Tailwind CSS
Tailwind CSS uses a utility-first approach. Instead of writing CSS files with custom selectors, you style elements by adding class names directly to them in your JSX.
Tailwind scans your files for class names and generates only the CSS you actually use, keeping your bundle small.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the expense-tracker folder on your Desktop, then click Open.
Now you need to tell Tailwind which files to scan for class names. The generated tailwind.config.js has an empty content array that needs your project's file paths.
- In the Explorer sidebar on the left, click tailwind.config.js to open it.
- Replace the entire contents of the file with this code:
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
What does this config do?
The content array tells Tailwind where to look for class names. It scans index.html and every file in src/ with a .js, .ts, .jsx, or .tsx extension.
Any Tailwind class it finds gets included in your final CSS. Classes it doesn't find get excluded, keeping your CSS small.
- Save tailwind.config.js.
Next, set up src/index.css with the Tailwind directives that load the framework's styles and apply a dark body styling.
- In the Explorer sidebar, expand the src/ folder and click index.css to open it.
- Select all existing content and replace it with this code:
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-gray-900 text-gray-100 min-h-screen;
}
What are these directives?
The three @tailwind directives inject Tailwind's reset styles, component classes, and utility classes respectively. Together they form the complete Tailwind framework.
The @apply line applies Tailwind utilities to the body element globally. This sets a dark gray background, light text, and full-height layout for the entire app.
- Save src/index.css.
Not sure if Tailwind is working?
You will verify Tailwind is active later when you start the dev server. If you see unstyled content at that point, check that src/index.css starts with the three @tailwind directives and that tailwind.config.js has the correct content paths.
help me troubleshoot my Tailwind CSS setup
Get your Gemini API key and create the folder structure
The app uses Google Gemini to analyze spending patterns. You need a free API key to connect to the Gemini service. No credit card is required.
- Open your browser and go to Google AI Studio.
- Sign in with your Google account.
- In the left-hand panel, click API keys.
- Click Create API key.
- Copy the generated key. You will use it in a moment.
Is this free?
Yes. The Gemini API free tier gives you enough requests for this project with no billing setup. Your data may be used to improve Google products on the free tier, but this is fine for a learning project.
Seeing a permission denied error?
Make sure you are signed in with a personal Google account. Workspace (company-managed) accounts sometimes block API key creation. Try opening Google AI Studio in an incognito window with your personal account.
help me get a Gemini API key
Now create a .env file to store your key securely. Vite automatically reads variables prefixed with VITE_ and makes them available in your code.
- Switch back to VS Code.
- In the Explorer sidebar, right-click in the empty space below the file list and select New File.
- Name the file .env and press Enter.
- Paste the following into .env, replacing your_api_key_here with your actual Gemini key:
VITE_GEMINI_API_KEY=your_api_key_here
- Save the .env file.
Keep your key out of Git
Vite's scaffold already includes a .gitignore file that excludes .env from version control. Your API key stays local and never gets pushed to GitHub.
Finally, create the folder structure for your React code. This keeps components, pages, hooks, and context organized from the start.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Create the project directories by running:
mkdir src/components src/pages src/hooks src/context
- Confirm the folders were created by running:
ls src
You should see components, context, hooks, and pages listed alongside the existing src/ files.
Folders not showing in VS Code?
Empty folders sometimes don't appear in the Explorer sidebar until they contain a file. Click the refresh icon at the top of the Explorer panel. They will populate as you add files in later steps.
help me verify my project folder structure is correct
✔️ Awesome, I've got everything!
Great. Double check you have saved tailwind.config.js, src/index.css, and .env.
ⓧ I'd like to double check the full code
Here are the files you should have at this point:
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-gray-900 text-gray-100 min-h-screen;
}
VITE_GEMINI_API_KEY=your_api_key_here
Your project skeleton is ready. Next up, you will build the expense data layer and navigation layout that ties the entire app together.
Build the Expense Data Layer and Layout
Your project is scaffolded and Tailwind CSS is configured. Now you need a way to store expenses and navigate between pages.
In this step, you'll create a global state layer using React's Context API so any component can read and update expenses. You'll also wire up React Router for multi-page navigation and build a styled layout shell with Tailwind CSS.
In this step, get ready to:
- Create an ExpenseContext to manage global expense state with localStorage persistence.
- Set up React Router with three page routes.
- Build a styled Layout component with Tailwind CSS navigation.
Create the expense context and state management
Every page in the app needs access to the same list of expenses. Instead of passing data through every component, React's Context API lets you share state globally.
- Create the expense context file by adding the following code to src/context/ExpenseContext.jsx:
import { createContext, useContext, useReducer, useEffect } from 'react'
const ExpenseContext = createContext()
const ExpenseDispatchContext = createContext()
// Load saved expenses from localStorage, or start with an empty array
const initialExpenses = JSON.parse(localStorage.getItem('expenses')) || []
// Reducer handles all expense state changes
function expenseReducer(expenses, action) {
switch (action.type) {
case 'ADD_EXPENSE':
return [...expenses, action.payload]
case 'DELETE_EXPENSE':
return expenses.filter(expense => expense.id !== action.payload)
default:
return expenses
}
}
export function ExpenseProvider({ children }) {
const [expenses, dispatch] = useReducer(expenseReducer, initialExpenses)
// Persist expenses to localStorage whenever they change
useEffect(() => {
localStorage.setItem('expenses', JSON.stringify(expenses))
}, [expenses])
return (
<ExpenseContext value={expenses}>
<ExpenseDispatchContext value={dispatch}>
{children}
</ExpenseDispatchContext>
</ExpenseContext>
)
}
// Custom hooks for easy access in any component
export function useExpenses() {
return useContext(ExpenseContext)
}
export function useExpenseDispatch() {
return useContext(ExpenseDispatchContext)
}
What does this code do?
This file sets up the entire data layer for your expense tracker using three React patterns working together.
- useReducer manages state changes through a reducer function. When you dispatch an action like ADD_EXPENSE, the reducer returns a new array with the expense appended.
- useEffect watches the expenses array and saves it to localStorage every time it changes. This means data survives page refreshes.
- useExpenses() and useExpenseDispatch() are custom hooks that give any component access to the expense list or the ability to modify it.
Why useReducer instead of useState?
When state changes follow predictable patterns (add, delete, update), a reducer keeps that logic centralized. As the app grows, you only need to add new cases to the switch statement instead of scattering logic across components.
- Save src/context/ExpenseContext.jsx.
You won't see a visible change yet. The next chunk wires this provider into the app so components can use it.
Set up React Router and page structure
Your app needs three pages: a Dashboard, an Add Expense form, and an AI Insights panel. React Router maps URL paths to components so the browser's back/forward buttons work like a traditional website.
- Replace the contents of src/App.jsx with the following route configuration:
import { Routes, Route } from 'react-router'
import Layout from './components/Layout.jsx'
import Dashboard from './pages/Dashboard.jsx'
import AddExpense from './pages/AddExpense.jsx'
import Insights from './pages/Insights.jsx'
// Route configuration: Layout wraps all pages, each path maps to a component
function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/add" element={<AddExpense />} />
<Route path="/insights" element={<Insights />} />
</Route>
</Routes>
)
}
export default App
What does this code do?
- The outer <Route element={<Layout />}> wraps all child routes in a shared Layout component. This means every page gets the same navigation bar.
- Each nested <Route> maps a URL path to a page component. Visiting / shows the Dashboard. Visiting /add shows the expense form.
- The Layout component will render an <Outlet /> where the matched child route appears.
The route imports reference page files that don't exist yet. You need to create placeholder versions so the app can load without errors.
- Create src/pages/Dashboard.jsx with this placeholder:
function Dashboard() {
return <h2>Dashboard</h2>
}
export default Dashboard
- Create src/pages/AddExpense.jsx with this placeholder:
function AddExpense() {
return <h2>Add Expense</h2>
}
export default AddExpense
- Create src/pages/Insights.jsx with this placeholder:
function Insights() {
return <h2>AI Insights</h2>
}
export default Insights
These placeholder files will be replaced with full implementations in later steps.
- Now update src/main.jsx to wrap the app with BrowserRouter and ExpenseProvider:
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import App from './App.jsx'
import { ExpenseProvider } from './context/ExpenseContext.jsx'
import './index.css'
// BrowserRouter enables URL-based navigation
// ExpenseProvider gives all components access to expense state
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<ExpenseProvider>
<App />
</ExpenseProvider>
</BrowserRouter>
</React.StrictMode>,
)
What does this code do?
- BrowserRouter enables client-side routing. It intercepts link clicks and updates the URL without a full page reload.
- ExpenseProvider wraps the entire app so every component inside can call useExpenses() or useExpenseDispatch() to read or modify expenses.
- The nesting order matters. BrowserRouter must be outside the app for routing to work. ExpenseProvider must be inside it so routes can access expense data.
- Save all four files: src/App.jsx, src/pages/Dashboard.jsx, src/pages/AddExpense.jsx, and src/main.jsx.
- Start (or check) the dev server by running:
npm run dev
- Open the URL shown in your terminal (typically http://localhost:5173). You should see "Dashboard" rendered as a plain heading on a dark background.
Seeing a blank page or import error?
- Check that all three placeholder page files exist in src/pages/. Missing files cause import errors that stop the app from loading.
- Verify the import paths in App.jsx match the filenames exactly, including the .jsx extension.
- If you see a "Cannot find module" error in the terminal, double-check the file path for ExpenseContext.jsx in main.jsx.
Still stuck? Why is my React app showing a blank page after adding React Router?
Style the layout and navigation with Tailwind
The Layout component is the shell that wraps every page. It provides the app title, a navigation bar with active-state highlighting, and a content area where the current page renders.
- Create src/components/Layout.jsx with the following code:
import { NavLink, Outlet } from 'react-router'
function Layout() {
// Dynamic class function: returns different Tailwind classes based on active state
const linkClass = ({ isActive }) =>
`px-4 py-2 rounded-lg transition-colors ${isActive ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'}`
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<header className="mb-8">
<h1 className="text-3xl font-bold text-white mb-4">SpendWise AI</h1>
<nav className="flex gap-2">
<NavLink to="/" className={linkClass}>Dashboard</NavLink>
<NavLink to="/add" className={linkClass}>Add Expense</NavLink>
<NavLink to="/insights" className={linkClass}>AI Insights</NavLink>
</nav>
</header>
<main>
<Outlet />
</main>
</div>
)
}
export default Layout
What do these Tailwind classes do?
- max-w-4xl mx-auto constrains the content width and centers it horizontally. This prevents the layout from stretching across ultra-wide screens.
- px-4 py-8 adds horizontal padding (16px) and vertical padding (32px) so content doesn't touch the edges.
- flex gap-2 on the nav lays out the links in a row with small gaps between them.
- rounded-lg gives each nav link rounded corners.
- transition-colors animates color changes smoothly when hovering or switching active state.
The linkClass function receives { isActive } from React Router's NavLink component. When the link matches the current URL, it gets bg-indigo-600 text-white (a bold purple background). Otherwise, it shows muted gray text with hover effects.
- Save src/components/Layout.jsx.
- Return to your browser. You should see "SpendWise AI" as a bold white heading, with three nav links below it: Dashboard (highlighted in purple), Add Expense, and AI Insights (both in gray).
- Click Add Expense in the nav. The link should turn purple, Dashboard should turn gray, and the page content should switch to "Add Expense".
Nav links not changing color?
- Make sure the linkClass function uses backtick template literals (not regular quotes) for the string interpolation to work.
- Check that you're importing NavLink (not Link) from react-router. Only NavLink provides the isActive callback.
- If styles aren't applying at all, confirm src/index.css still has the three @tailwind directives and is imported in main.jsx.
Still stuck? Why aren't my Tailwind classes or NavLink active styles working?
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file you created or modified in this step.
import { createContext, useContext, useReducer, useEffect } from 'react'
const ExpenseContext = createContext()
const ExpenseDispatchContext = createContext()
const initialExpenses = JSON.parse(localStorage.getItem('expenses')) || []
function expenseReducer(expenses, action) {
switch (action.type) {
case 'ADD_EXPENSE':
return [...expenses, action.payload]
case 'DELETE_EXPENSE':
return expenses.filter(expense => expense.id !== action.payload)
default:
return expenses
}
}
export function ExpenseProvider({ children }) {
const [expenses, dispatch] = useReducer(expenseReducer, initialExpenses)
useEffect(() => {
localStorage.setItem('expenses', JSON.stringify(expenses))
}, [expenses])
return (
<ExpenseContext value={expenses}>
<ExpenseDispatchContext value={dispatch}>
{children}
</ExpenseDispatchContext>
</ExpenseContext>
)
}
export function useExpenses() {
return useContext(ExpenseContext)
}
export function useExpenseDispatch() {
return useContext(ExpenseDispatchContext)
}
import { Routes, Route } from 'react-router'
import Layout from './components/Layout.jsx'
import Dashboard from './pages/Dashboard.jsx'
import AddExpense from './pages/AddExpense.jsx'
import Insights from './pages/Insights.jsx'
function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/add" element={<AddExpense />} />
<Route path="/insights" element={<Insights />} />
</Route>
</Routes>
)
}
export default App
import { NavLink, Outlet } from 'react-router'
function Layout() {
const linkClass = ({ isActive }) =>
`px-4 py-2 rounded-lg transition-colors ${isActive ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'}`
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<header className="mb-8">
<h1 className="text-3xl font-bold text-white mb-4">SpendWise AI</h1>
<nav className="flex gap-2">
<NavLink to="/" className={linkClass}>Dashboard</NavLink>
<NavLink to="/add" className={linkClass}>Add Expense</NavLink>
<NavLink to="/insights" className={linkClass}>AI Insights</NavLink>
</nav>
</header>
<main>
<Outlet />
</main>
</div>
)
}
export default Layout
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import App from './App.jsx'
import { ExpenseProvider } from './context/ExpenseContext.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<ExpenseProvider>
<App />
</ExpenseProvider>
</BrowserRouter>
</React.StrictMode>,
)
Your data layer is in place and the navigation is working. Next up, you'll build the Add Expense form so users can actually submit spending entries.
Build the Add Expense Page
Your layout and navigation are working. Users can click between pages, but every page is still a placeholder.
The Add Expense page is where users actually interact with your app. In this step, you will build a complete form with input validation, Tailwind styling, and a direct connection to the global expense state you created in the last step.
In this step, get ready to:
- Build a form with controlled inputs for expense data.
- Style the form with Tailwind CSS utility classes.
- Connect the form to the ExpenseContext for data persistence.
Create the expense form with controlled inputs
In React, a controlled input is a form element whose value is driven by component state. Every keystroke updates state, and state flows back into the input's value prop. This keeps your data and UI perfectly in sync.
- In VS Code, open src/pages/AddExpense.jsx and replace all of its contents with this code:
import { useState } from 'react'
import { useExpenseDispatch } from '../context/ExpenseContext.jsx'
import { useNavigate } from 'react-router'
const CATEGORIES = ['Food', 'Transport', 'Entertainment', 'Shopping', 'Bills', 'Health', 'Education', 'Other']
function AddExpense() {
const dispatch = useExpenseDispatch()
const navigate = useNavigate()
// Each form field gets its own state variable
const [amount, setAmount] = useState('')
const [category, setCategory] = useState('Food')
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const [description, setDescription] = useState('')
const [error, setError] = useState('')
function handleSubmit(e) {
e.preventDefault()
// Validate amount is a positive number
if (!amount || Number(amount) <= 0) {
setError('Please enter a valid amount.')
return
}
// Validate description is not empty
if (!description.trim()) {
setError('Please enter a description.')
return
}
// Dispatch the new expense to global state
dispatch({
type: 'ADD_EXPENSE',
payload: {
id: crypto.randomUUID(),
amount: Number(amount),
category,
date,
description: description.trim(),
}
})
// Navigate back to the dashboard
navigate('/')
}
return (
<div className="max-w-md mx-auto">
<h2 className="text-2xl font-bold mb-6">Add Expense</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{error && <p className="text-red-400 text-sm">{error}</p>}
<div>
<label className="block text-sm text-gray-400 mb-1">Amount ($)</label>
<input
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-indigo-500"
placeholder="0.00"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-indigo-500"
>
{CATEGORIES.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Description</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-indigo-500"
placeholder="Coffee at Starbucks"
/>
</div>
<button
type="submit"
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 rounded-lg transition-colors"
>
Add Expense
</button>
</form>
</div>
)
}
export default AddExpense
What does this code do?
Five useState hooks create controlled inputs. Each input's value prop reads from state, and its onChange handler writes back to state on every keystroke.
The handleSubmit function runs when the form is submitted. It first validates that the amount is positive and the description is not empty. If validation passes, it dispatches an ADD_EXPENSE action to the global context, then navigates the user back to the dashboard.
The CATEGORIES array lives outside the component because it never changes. This avoids recreating the array on every render.
- Save src/pages/AddExpense.jsx.
- In your browser, click the Add Expense link in the navigation bar. You should see a dark form with four labeled fields (Amount, Category, Date, Description) and a purple Add Expense button.
Don't see the form?
- Make sure you saved the file. Unsaved changes will not trigger a browser refresh.
- Check that the file is saved as src/pages/AddExpense.jsx (not in the wrong folder).
- Look at your terminal for red error text. A missing comma or bracket will prevent the page from rendering.
Help me debug my AddExpense form not rendering.
Understand the Tailwind form styling
The form you just wrote uses several Tailwind CSS patterns that are common across professional dark-themed UIs. Here is what each pattern does.
What do these Tailwind classes do?
- space-y-4 adds consistent vertical spacing between each form field without manually adding margin to every element.
- w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 creates full-width inputs with a dark background, subtle border, rounded corners, and comfortable padding.
- focus:outline-none focus:border-indigo-500 removes the browser's default blue outline and replaces it with a purple border when the input is focused. This is called a focus ring.
- bg-indigo-600 hover:bg-indigo-700 transition-colors on the button creates a purple background that darkens smoothly on hover.
Why max-w-md mx-auto?
Forms that stretch the full width of the screen are harder to read and feel overwhelming. The max-w-md class caps the form at a medium width, and mx-auto centers it horizontally. This keeps the form compact and easy to scan.
Test the context connection
Your form is wired to the global ExpenseContext through two hooks. When the user submits, the data flows from local form state into global app state, then persists to localStorage.
How does the form connect to global state?
- useExpenseDispatch() gives the component access to the dispatch function from your context. Calling dispatch({ type: 'ADD_EXPENSE', payload: {...} }) triggers the reducer you built in the previous step.
- useNavigate() from React Router lets you programmatically redirect the user after a successful submission. Calling navigate('/') sends them back to the Dashboard.
- crypto.randomUUID() generates a unique ID for each expense. This is a built-in browser API that creates a random UUID without any extra libraries.
- In your browser, navigate to the Add Expense page.
- Enter 12.50 in the Amount field.
- Select Food from the Category dropdown.
- Leave the date as today.
- Type Test expense in the Description field.
- Click Add Expense.
You should be redirected back to the Dashboard page. The expense is now stored in localStorage.
- Verify persistence by opening your browser's DevTools (press Cmd+Option+I on macOS or Ctrl+Shift+I on Windows).
- Click the Application tab, then expand Local Storage in the sidebar.
- Click on your localhost entry. You should see an expenses key containing a JSON array with your test expense.
Form submits but nothing appears in localStorage?
- Check your terminal for errors. If useExpenseDispatch is not imported correctly, the dispatch call will fail silently.
- Make sure the import path says ../context/ExpenseContext.jsx with the correct relative path from the pages folder.
- Try submitting with an empty description. You should see a red error message. If you do not, the form's onSubmit may not be connected to handleSubmit.
Help me debug my expense form submission.
Also try submitting the form with an empty amount or description to confirm validation works. You should see a red error message appear above the fields.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/pages/AddExpense.jsx before moving on.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import { useExpenseDispatch } from '../context/ExpenseContext.jsx'
import { useNavigate } from 'react-router'
const CATEGORIES = ['Food', 'Transport', 'Entertainment', 'Shopping', 'Bills', 'Health', 'Education', 'Other']
function AddExpense() {
const dispatch = useExpenseDispatch()
const navigate = useNavigate()
const [amount, setAmount] = useState('')
const [category, setCategory] = useState('Food')
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const [description, setDescription] = useState('')
const [error, setError] = useState('')
function handleSubmit(e) {
e.preventDefault()
if (!amount || Number(amount) <= 0) {
setError('Please enter a valid amount.')
return
}
if (!description.trim()) {
setError('Please enter a description.')
return
}
dispatch({
type: 'ADD_EXPENSE',
payload: {
id: crypto.randomUUID(),
amount: Number(amount),
category,
date,
description: description.trim(),
}
})
navigate('/')
}
return (
<div className="max-w-md mx-auto">
<h2 className="text-2xl font-bold mb-6">Add Expense</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{error && <p className="text-red-400 text-sm">{error}</p>}
<div>
<label className="block text-sm text-gray-400 mb-1">Amount ($)</label>
<input
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-indigo-500"
placeholder="0.00"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-indigo-500"
>
{CATEGORIES.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Description</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-indigo-500"
placeholder="Coffee at Starbucks"
/>
</div>
<button
type="submit"
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 rounded-lg transition-colors"
>
Add Expense
</button>
</form>
</div>
)
}
export default AddExpense
Your form captures expenses and persists them to the browser. Next up, you will build the Dashboard with interactive charts that visualize all this spending data.
Build the Dashboard with Charts
You can now add expenses and they persist in localStorage. The problem is your Dashboard page still shows nothing useful.
In this step, you will build a data-rich Dashboard that instantly tells you where your money is going. You will use Recharts to render a pie chart by category and a bar chart by month, plus summary cards at the top.
In this step, get ready to:
- Create summary cards showing total spending, transaction count, and top category.
- Build a PieChart showing spending breakdown by category.
- Build a BarChart showing monthly spending trends.
Create summary cards showing spending stats
The dashboard needs a quick-glance summary before any charts. Three cards across the top will show total spending, number of transactions, and the category where you spend the most.
- In VS Code, open src/pages/Dashboard.jsx and replace its entire contents with this code:
import { useExpenses } from '../context/ExpenseContext.jsx'
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
// Color palette for pie chart slices
const COLORS = ['#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#f97316', '#eab308', '#22c55e', '#06b6d4']
// Must match the categories from AddExpense
const CATEGORIES = ['Food', 'Transport', 'Entertainment', 'Shopping', 'Bills', 'Health', 'Education', 'Other']
function Dashboard() {
const expenses = useExpenses()
// Calculate summary stats from the expense list
const totalSpending = expenses.reduce((sum, e) => sum + Number(e.amount), 0)
const totalTransactions = expenses.length
// Group spending by category, filtering out empty ones
const categoryData = CATEGORIES.map(cat => ({
name: cat,
value: expenses.filter(e => e.category === cat).reduce((sum, e) => sum + Number(e.amount), 0)
})).filter(d => d.value > 0)
// Group spending by month for the bar chart
const monthlyData = expenses.reduce((acc, e) => {
const month = e.date.slice(0, 7)
acc[month] = (acc[month] || 0) + Number(e.amount)
return acc
}, {})
const barData = Object.entries(monthlyData).map(([month, total]) => ({ month, total })).sort((a, b) => a.month.localeCompare(b.month))
// Find which category has the highest total
const topCategory = categoryData.length > 0 ? categoryData.reduce((a, b) => a.value > b.value ? a : b).name : 'N/A'
return (
<div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="bg-gray-800 rounded-xl p-6">
<p className="text-gray-400 text-sm">Total Spending</p>
<p className="text-2xl font-bold text-white">${totalSpending.toFixed(2)}</p>
</div>
<div className="bg-gray-800 rounded-xl p-6">
<p className="text-gray-400 text-sm">Transactions</p>
<p className="text-2xl font-bold text-white">{totalTransactions}</p>
</div>
<div className="bg-gray-800 rounded-xl p-6">
<p className="text-gray-400 text-sm">Top Category</p>
<p className="text-2xl font-bold text-white">{topCategory}</p>
</div>
</div>
</div>
)
}
export default Dashboard
What does this code do?
This code computes all the data the dashboard needs from the expense list, then displays three stat cards in a responsive grid.
- useExpenses() pulls the global expense array from context.
- categoryData groups spending totals by category and filters out categories with zero spending.
- monthlyData and barData aggregate expenses by their date's year-month for the bar chart.
- grid grid-cols-1 md:grid-cols-3 gap-4 creates a responsive layout: one column on mobile, three columns on desktop screens.
What is CSS Grid in Tailwind?
Tailwind CSS wraps CSS Grid into utility classes. grid activates grid layout, grid-cols-3 creates three equal columns, and the md: prefix means that rule only applies on medium-and-larger screens.
On small screens the cards stack vertically. On wider screens they sit side by side. No media queries needed.
- Save src/pages/Dashboard.jsx.
- Refresh your browser and click the Dashboard nav link. You should see three dark cards showing $0.00, 0 transactions, and N/A for top category.
Don't see the summary cards?
- Check the terminal for import errors. Make sure the Recharts import line is exactly as shown, with no missing commas between component names.
- Confirm you saved the file. Unsaved changes won't trigger a hot reload.
- help me debug my Dashboard summary cards not showing up
Build the PieChart for spending by category
Numbers alone are hard to interpret at a glance. A pie chart lets you instantly see which category eats the biggest slice of your budget.
- In src/pages/Dashboard.jsx, find the closing </div> of the summary cards grid (the one right after the Top Category card).
- Add this code directly below it, before the outer closing </div>:
{expenses.length === 0 ? (
<p className="text-gray-400 text-center py-12">No expenses yet. Add some to see your charts!</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="bg-gray-800 rounded-xl p-6">
<h2 className="text-lg font-semibold mb-4">Spending by Category</h2>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={categoryData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80}>
{categoryData.map((entry, index) => (
<Cell key={entry.name} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value) => `$${value.toFixed(2)}`} />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
)}
What does this code do?
- The ternary checks if there are any expenses. If none exist, it shows a friendly empty-state message instead of broken charts.
- ResponsiveContainer makes the chart resize fluidly to fit its parent container.
- Each Cell gets a color from the COLORS array using modulo so colors cycle if you exceed 8 categories.
- The Tooltip formatter adds a dollar sign and two decimal places on hover.
- grid grid-cols-1 md:grid-cols-2 gap-8 gives the charts a two-column layout on desktop but stacks them on mobile.
- Save src/pages/Dashboard.jsx.
- Refresh your browser. If you have no expenses yet, you should see the message "No expenses yet. Add some to see your charts!" below the stat cards.
- To test the chart, click Add Expense in the nav, submit a few expenses across different categories, then click Dashboard. You should see a colored pie chart with a legend underneath.
Pie chart not appearing after adding expenses?
- Make sure the new code is placed after the summary cards grid closing </div> but still inside the outer <div> of the return statement.
- Check for mismatched curly braces. The ternary uses {expenses.length === 0 ? (...) : (...)} with parentheses wrapping each branch.
- help me debug my Recharts PieChart not rendering
Build the BarChart for monthly spending
The pie chart tells you where money goes. A bar chart tells you when you spend the most, making it easy to spot months where spending spiked.
- In src/pages/Dashboard.jsx, find the closing </div> that ends the PieChart card (the one after </ResponsiveContainer> for the pie chart).
- Add this BarChart card directly below it, still inside the grid grid-cols-1 md:grid-cols-2 gap-8 container:
<div className="bg-gray-800 rounded-xl p-6">
<h2 className="text-lg font-semibold mb-4">Monthly Spending</h2>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={barData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="month" stroke="#9ca3af" />
<YAxis stroke="#9ca3af" />
<Tooltip formatter={(value) => `$${value.toFixed(2)}`} />
<Bar dataKey="total" fill="#6366f1" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
What does this code do?
- CartesianGrid draws faint dashed lines behind the bars. The stroke="#374151" color matches the dark theme.
- XAxis and YAxis use stroke="#9ca3af" for subtle gray axis labels that stay readable.
- radius={[4, 4, 0, 0]} rounds only the top corners of each bar, giving a polished look.
- Because this card sits inside the same md:grid-cols-2 grid as the pie chart, the two charts display side by side on desktop and stack on mobile.
- Save src/pages/Dashboard.jsx.
- Refresh your browser. You should see the pie chart on the left and a bar chart on the right (or stacked if your window is narrow). Each bar represents a month's total spending.
Bar chart shows but has no bars?
- The bar chart uses barData which groups expenses by their date's year-month. Make sure your test expenses have dates (the form defaults to today, so they should).
- Confirm the dataKey="total" matches the property name in your barData objects.
- help me debug my Recharts BarChart not rendering bars
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/pages/Dashboard.jsx for comparison:
import { useExpenses } from '../context/ExpenseContext.jsx'
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
const COLORS = ['#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#f97316', '#eab308', '#22c55e', '#06b6d4']
const CATEGORIES = ['Food', 'Transport', 'Entertainment', 'Shopping', 'Bills', 'Health', 'Education', 'Other']
function Dashboard() {
const expenses = useExpenses()
const totalSpending = expenses.reduce((sum, e) => sum + Number(e.amount), 0)
const totalTransactions = expenses.length
const categoryData = CATEGORIES.map(cat => ({
name: cat,
value: expenses.filter(e => e.category === cat).reduce((sum, e) => sum + Number(e.amount), 0)
})).filter(d => d.value > 0)
const monthlyData = expenses.reduce((acc, e) => {
const month = e.date.slice(0, 7)
acc[month] = (acc[month] || 0) + Number(e.amount)
return acc
}, {})
const barData = Object.entries(monthlyData).map(([month, total]) => ({ month, total })).sort((a, b) => a.month.localeCompare(b.month))
const topCategory = categoryData.length > 0 ? categoryData.reduce((a, b) => a.value > b.value ? a : b).name : 'N/A'
return (
<div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="bg-gray-800 rounded-xl p-6">
<p className="text-gray-400 text-sm">Total Spending</p>
<p className="text-2xl font-bold text-white">${totalSpending.toFixed(2)}</p>
</div>
<div className="bg-gray-800 rounded-xl p-6">
<p className="text-gray-400 text-sm">Transactions</p>
<p className="text-2xl font-bold text-white">{totalTransactions}</p>
</div>
<div className="bg-gray-800 rounded-xl p-6">
<p className="text-gray-400 text-sm">Top Category</p>
<p className="text-2xl font-bold text-white">{topCategory}</p>
</div>
</div>
{expenses.length === 0 ? (
<p className="text-gray-400 text-center py-12">No expenses yet. Add some to see your charts!</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="bg-gray-800 rounded-xl p-6">
<h2 className="text-lg font-semibold mb-4">Spending by Category</h2>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={categoryData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80}>
{categoryData.map((entry, index) => (
<Cell key={entry.name} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value) => `$${value.toFixed(2)}`} />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
<div className="bg-gray-800 rounded-xl p-6">
<h2 className="text-lg font-semibold mb-4">Monthly Spending</h2>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={barData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="month" stroke="#9ca3af" />
<YAxis stroke="#9ca3af" />
<Tooltip formatter={(value) => `$${value.toFixed(2)}`} />
<Bar dataKey="total" fill="#6366f1" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
</div>
)
}
export default Dashboard
Your Dashboard now gives you a complete visual overview of your spending. Next up, you will build the AI Insights page that sends your expense data to Google Gemini and gets back personalized financial analysis.
Build the AI Insights Page
Your dashboard now visualizes spending with charts and summary cards. Numbers are great, but they don't tell you why you're overspending or what to change.
In this step, you'll connect your app to Google Gemini to analyze expense data and return actionable insights. You'll build a custom React hook that handles the API call, then create the Insights page with loading animations and color-coded result cards.
In this step, get ready to:
- Create a custom useGemini hook for AI integration with loading and error states.
- Build the Insights page UI with skeleton loading and styled result cards.
- Test the AI insights feature end-to-end with disabled state handling.
Create the useGemini custom hook
A custom hook is a function that encapsulates reusable logic. Your useGemini hook will manage the full lifecycle of an AI request: sending expense data to the Gemini API, tracking whether the request is loading, and handling errors gracefully.
- Create a new file called useGemini.js inside the src/hooks/ directory, then paste the following code:
import { useState } from 'react'
import { GoogleGenAI } from '@google/genai'
// Initialize the Gemini SDK with your API key from .env
const ai = new GoogleGenAI({ apiKey: import.meta.env.VITE_GEMINI_API_KEY })
export function useGemini() {
const [insights, setInsights] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
async function getInsights(expenses) {
setLoading(true)
setError(null)
// Format each expense into a readable string for the AI
const expenseSummary = expenses.map(e => `${e.date} - ${e.category}: $${e.amount} (${e.description})`).join('\n')
// Prompt instructs Gemini to return structured JSON with three arrays
const prompt = `You are a personal finance assistant. Analyze these expenses and provide insights about spending habits, patterns, and suggestions to save money. Format your response as JSON with three arrays: "patterns" (3 observations about spending habits), "warnings" (any concerning trends), and "suggestions" (3 actionable tips to save money). Only return valid JSON, no markdown.\n\nExpenses:\n${expenseSummary}`
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: prompt,
})
// Parse the JSON string from Gemini's response
const parsed = JSON.parse(response.text)
setInsights(parsed)
} catch (err) {
setError('Failed to get AI insights. Please try again.')
} finally {
setLoading(false)
}
}
return { insights, loading, error, getInsights }
}
What does this code do?
The hook exposes four values: insights (the parsed AI response), loading (true while the request is in-flight), error (a user-friendly message if something goes wrong), and getInsights (the function to trigger the API call).
The prompt asks Gemini to return structured JSON with three arrays: patterns, warnings, and suggestions. This makes it easy to render each section in its own styled card on the page.
The finally block ensures setLoading(false) runs whether the request succeeds or fails, so the UI never gets stuck in a loading state.
- Save src/hooks/useGemini.js.
- You won't see a change in the browser yet. The next substep uses this hook in the Insights page.
Seeing a red underline on the import?
If your editor shows a warning on import { GoogleGenAI } from '@google/genai', make sure you installed the @google/genai package in Step 1. If you missed it, run npm install @google/genai in your terminal.
help me fix my @google/genai import error
What is a custom hook?
In React, any function whose name starts with use can call other hooks (like useState) and is treated as a custom hook. This lets you extract complex logic (API calls, subscriptions, form handling) out of your components and reuse it anywhere.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import { GoogleGenAI } from '@google/genai'
const ai = new GoogleGenAI({ apiKey: import.meta.env.VITE_GEMINI_API_KEY })
export function useGemini() {
const [insights, setInsights] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
async function getInsights(expenses) {
setLoading(true)
setError(null)
const expenseSummary = expenses.map(e => `${e.date} - ${e.category}: $${e.amount} (${e.description})`).join('\n')
const prompt = `You are a personal finance assistant. Analyze these expenses and provide insights about spending habits, patterns, and suggestions to save money. Format your response as JSON with three arrays: "patterns" (3 observations about spending habits), "warnings" (any concerning trends), and "suggestions" (3 actionable tips to save money). Only return valid JSON, no markdown.\n\nExpenses:\n${expenseSummary}`
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: prompt,
})
const parsed = JSON.parse(response.text)
setInsights(parsed)
} catch (err) {
setError('Failed to get AI insights. Please try again.')
} finally {
setLoading(false)
}
}
return { insights, loading, error, getInsights }
}
Build the Insights page UI
The Insights page needs to handle three states: empty (no expenses yet), loading (waiting for Gemini), and results (showing the AI analysis). Each state gets its own visual treatment using Tailwind utility classes.
- Open src/pages/Insights.jsx and replace all its contents with the following code:
import { useExpenses } from '../context/ExpenseContext.jsx'
import { useGemini } from '../hooks/useGemini.js'
function Insights() {
const expenses = useExpenses()
const { insights, loading, error, getInsights } = useGemini()
// Only call getInsights when there are expenses to analyze
function handleRefresh() {
if (expenses.length > 0) {
getInsights(expenses)
}
}
return (
<div>
{/* Header with title and action button aligned to opposite ends */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">AI Insights</h2>
<button
onClick={handleRefresh}
disabled={loading || expenses.length === 0}
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded-lg transition-colors"
>
{loading ? 'Analyzing...' : 'Get Insights'}
</button>
</div>
{/* Empty state when no expenses exist */}
{expenses.length === 0 && (
<p className="text-gray-400 text-center py-12">Add some expenses first to get AI insights.</p>
)}
{/* Error message from failed API call */}
{error && <p className="text-red-400 mb-4">{error}</p>}
{/* Loading skeleton with pulsing animation */}
{loading && (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-gray-800 rounded-xl p-6 animate-pulse">
<div className="h-4 bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-700 rounded w-1/2"></div>
</div>
))}
</div>
)}
{/* AI results displayed in color-coded cards */}
{insights && !loading && (
<div className="space-y-6">
<div className="bg-gray-800 rounded-xl p-6">
<h3 className="text-lg font-semibold text-indigo-400 mb-3">Spending Patterns</h3>
<ul className="space-y-2">
{insights.patterns.map((pattern, i) => (
<li key={i} className="text-gray-300">{pattern}</li>
))}
</ul>
</div>
{insights.warnings.length > 0 && (
<div className="bg-gray-800 rounded-xl p-6 border border-red-900">
<h3 className="text-lg font-semibold text-red-400 mb-3">Warnings</h3>
<ul className="space-y-2">
{insights.warnings.map((warning, i) => (
<li key={i} className="text-gray-300">{warning}</li>
))}
</ul>
</div>
)}
<div className="bg-gray-800 rounded-xl p-6">
<h3 className="text-lg font-semibold text-green-400 mb-3">Suggestions</h3>
<ul className="space-y-2">
{insights.suggestions.map((suggestion, i) => (
<li key={i} className="text-gray-300">{suggestion}</li>
))}
</ul>
</div>
</div>
)}
</div>
)
}
export default Insights
What does this code do?
- animate-pulse creates a smooth fading animation on the skeleton cards while the API response loads. This gives users a visual cue that something is happening.
- The disabled: modifier on the button applies styles only when the button is disabled. Here, disabled:bg-gray-700 and disabled:text-gray-500 make the button look inactive when loading or when there are no expenses.
- Each insight section uses a different header color: text-indigo-400 for patterns, text-red-400 for warnings, and text-green-400 for suggestions. This color coding helps users scan results quickly.
- The warnings card adds border border-red-900 to visually distinguish it from the other cards, and it only renders when Gemini returns warnings.
- flex items-center justify-between in the header pushes the title to the left and the button to the right edge, creating a clean layout without manual spacing.
- Save src/pages/Insights.jsx.
- Switch back to your browser and click AI Insights in the nav bar. You should see the "AI Insights" heading on the left and a Get Insights button on the right.
Button not showing up?
Make sure you saved both useGemini.js and Insights.jsx. If you see a blank page or error in the browser console, check that the import path in Insights.jsx matches ../hooks/useGemini.js exactly (including the .js extension).
help me debug my Insights page not rendering
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { useExpenses } from '../context/ExpenseContext.jsx'
import { useGemini } from '../hooks/useGemini.js'
function Insights() {
const expenses = useExpenses()
const { insights, loading, error, getInsights } = useGemini()
function handleRefresh() {
if (expenses.length > 0) {
getInsights(expenses)
}
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">AI Insights</h2>
<button
onClick={handleRefresh}
disabled={loading || expenses.length === 0}
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded-lg transition-colors"
>
{loading ? 'Analyzing...' : 'Get Insights'}
</button>
</div>
{expenses.length === 0 && (
<p className="text-gray-400 text-center py-12">Add some expenses first to get AI insights.</p>
)}
{error && <p className="text-red-400 mb-4">{error}</p>}
{loading && (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-gray-800 rounded-xl p-6 animate-pulse">
<div className="h-4 bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-700 rounded w-1/2"></div>
</div>
))}
</div>
)}
{insights && !loading && (
<div className="space-y-6">
<div className="bg-gray-800 rounded-xl p-6">
<h3 className="text-lg font-semibold text-indigo-400 mb-3">Spending Patterns</h3>
<ul className="space-y-2">
{insights.patterns.map((pattern, i) => (
<li key={i} className="text-gray-300">{pattern}</li>
))}
</ul>
</div>
{insights.warnings.length > 0 && (
<div className="bg-gray-800 rounded-xl p-6 border border-red-900">
<h3 className="text-lg font-semibold text-red-400 mb-3">Warnings</h3>
<ul className="space-y-2">
{insights.warnings.map((warning, i) => (
<li key={i} className="text-gray-300">{warning}</li>
))}
</ul>
</div>
)}
<div className="bg-gray-800 rounded-xl p-6">
<h3 className="text-lg font-semibold text-green-400 mb-3">Suggestions</h3>
<ul className="space-y-2">
{insights.suggestions.map((suggestion, i) => (
<li key={i} className="text-gray-300">{suggestion}</li>
))}
</ul>
</div>
</div>
)}
</div>
)
}
export default Insights
Test AI insights end-to-end
The button combines multiple Tailwind state modifiers on a single element. The hover: modifier changes the background on mouse hover, while disabled: overrides both the background and text color when the button is disabled. Tailwind applies the most specific state, so disabled styles take priority.
- Make sure you have at least 3-4 expenses added from the previous step (if not, navigate to Add Expense and add a few with different categories and dates).
- Click AI Insights in the nav bar.
- Click the Get Insights button.
- Watch for the pulsing skeleton cards while Gemini processes your data.
- After a few seconds, you should see three colored sections: Spending Patterns (indigo header), Warnings (red header with a red border), and Suggestions (green header).
Why does the button text change?
The button uses a ternary expression: {loading ? 'Analyzing...' : 'Get Insights'}. While the API call is in progress, the loading state is true, so the button reads "Analyzing..." and is simultaneously disabled to prevent duplicate requests.
Getting "Failed to get AI insights"?
This means the API call failed. Check that your .env file contains a valid Gemini API key (not the placeholder your_api_key_here). You also need to restart the dev server after changing .env values. Stop the server with Ctrl+C and run npm run dev again.
help me fix my Gemini API connection
Your expense tracker can now analyze spending patterns with AI. Next up, you'll deploy the whole app to Vercel so anyone can access it from a live URL.
Deploy to Vercel
Your app works locally, but a portfolio project needs a public URL. In this step, you will push your code to GitHub and deploy it to Vercel so anyone can use your expense tracker.
Vercel is a hosting platform purpose-built for frontend frameworks. It auto-detects your Vite project, builds it, and serves it on a global CDN for free.
In this step, get ready to:
- Push your project to a new GitHub repository.
- Deploy to Vercel with your environment variable configured.
- Verify the live deployment works end-to-end.
Push your project to GitHub
Before Vercel can deploy your app, the code needs to live in a GitHub repository. You will initialize a local Git repo, commit all your files, then push them to a new repository on GitHub.
- Stop your dev server by pressing Ctrl+C in the terminal.
- Initialize a Git repository and make your first commit by running these commands:
git init
git add .
git commit -m "Initial commit"
What do these commands do?
- git init turns your project folder into a Git repository so it can track changes.
- git add . stages every file in the project for committing.
- git commit -m "Initial commit" saves a snapshot of all your files with a message describing the change.
What about the .env file?
Vite's scaffold includes a .gitignore file that already excludes .env files from Git. Your API key stays on your local machine and never gets pushed to GitHub.
- Open your browser and go to github.com/new to create a new repository.
- Enter spendwise-ai as the repository name.
- Leave the repository set to Public.
- Do NOT check any of the initialization options (README, .gitignore, or license). You already have code to push.
- Click Create repository.
GitHub shows a Quick Setup page with commands. You only need the remote URL from it.
- Connect your local repo to GitHub and push your code by running these commands:
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/spendwise-ai.git
git branch -M main
git push -u origin main
What do these commands do?
- git remote add origin tells Git where to push your code on the internet.
- git branch -M main renames the default branch to main (the standard convention).
- git push -u origin main uploads all your committed files to GitHub.
- Refresh the GitHub page in your browser. You should see all your project files listed in the repository.
Push failed or asking for authentication?
- If Git asks for a password, you need a personal access token instead. GitHub no longer accepts passwords for Git operations. Generate a token at github.com/settings/tokens and use it as your password.
- If you see remote origin already exists, your project was already linked to a remote. Remove it first with git remote remove origin, then re-run the git remote add command.
ask me for help pushing to GitHub
Deploy to Vercel
With your code on GitHub, Vercel can pull it down, build it, and host it automatically. Every future push to your main branch triggers a fresh deployment.
- Open vercel.com in your browser.
- Sign up or log in with your GitHub account.
- Click Add New... and select Project.
- Find spendwise-ai in the repository list and click Import.
Don't see your repository?
If the repo is missing from the list, click Configure GitHub App to grant Vercel access to your new repository. Select your GitHub account, then choose the spendwise-ai repo (or grant access to all repos).
Vercel shows a configuration page before deploying. You need to add your API key as an environment variable here.
- Expand the Environment Variables section on the configuration page.
- In the Key field, enter VITE_GEMINI_API_KEY.
- In the Value field, paste the same API key you have in your local .env file.
- Click Deploy.
Why add the environment variable here?
Your .env file is excluded from Git, so Vercel does not have access to it. You need to set the same variable in Vercel's project settings so the deployed app can reach the Gemini API.
Vercel now builds your project. This takes about 30-60 seconds. When finished, you see a success screen with a link to your live site.
- Click the production URL shown on the success page (it looks like https://spendwise-ai.vercel.app or similar).
- Confirm the app loads and you see the SpendWise AI dashboard with the navigation bar.
Build failed on Vercel?
- Check the build logs on the deployment page. If you see an error about a missing module, make sure you committed your package.json and package-lock.json files.
- If the build succeeds but the page is blank, check that VITE_GEMINI_API_KEY is spelled exactly right in Vercel's environment variables. Vite only exposes variables prefixed with VITE_.
ask me for help with a failed Vercel deployment
Verify the live deployment end-to-end
A deployment is only useful if it works. You will now test every feature on the live URL to confirm the app runs correctly in production.
- On your live Vercel URL, click Add Expense in the navigation bar.
- Enter 25 for the amount.
- Select Food as the category.
- Enter Lunch at cafe for the description.
- Click Add Expense.
- Confirm you are redirected to the Dashboard and can see the pie chart and bar chart with your new expense.
- Click AI Insights in the navigation bar.
- Click the Get Insights button.
- Confirm the skeleton loading animation appears, then AI-generated spending patterns, warnings, and suggestions load.
AI Insights not loading on the live site?
- If you see the error message but charts work fine, your VITE_GEMINI_API_KEY environment variable might be missing or misspelled. Go to your Vercel project settings, check the environment variables, and redeploy.
- Remember that Vite bakes environment variables into the build at build time. If you add or change a variable, you need to trigger a new deployment for it to take effect.
ask me for help with AI Insights not working on Vercel
Your AI expense tracker is live on the internet. Anyone with the URL can see your work. Next up, tackle the Secret Mission to add budget goals with AI-powered spending warnings.
Secret mission
Budget Goals with AI Warnings
Your expense tracker shows spending patterns, but it does not help you stay within limits. In this bonus challenge, you will add a budget goal system where you set monthly limits per category. The AI will warn you when you are approaching or exceeding those limits, and the Dashboard will show color-coded progress bars for each budget.
Clean Up Your Resources
Clean Up Your Resources
This project runs on Vercel's free tier and uses the Gemini API free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local project folder on your Desktop.
- GitHub repository.
- Vercel deployment.
- Gemini API key (in Google AI Studio).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep your live site accessible.
- Your Vercel deployment stays live at no cost on the free tier.
- Your Gemini API key remains active. The free tier has low rate limits so there is no risk of surprise charges.
- Your local project files remain on your Desktop.
Pause - I'll come back to this later
Free up local resources while keeping your cloud setup intact for later.
- If your dev server is still running in the terminal, press Ctrl+C to stop it.
- Your Vercel deployment and GitHub repo stay active at no cost. You can return and redeploy anytime.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below.
- Delete your local project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf expense-tracker
- On Windows, run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force expense-tracker
- Delete your GitHub repository. Open your repository on GitHub, click Settings, scroll down to the Danger Zone section, and click Delete this repository. Confirm by typing the repository name.
- Delete your Vercel project. In the Vercel dashboard, select your project, open Settings in the sidebar, scroll to the bottom of the General page, and click Delete in the Delete Project section. Enter your project name to confirm.
- Delete your Gemini API key. Open the Google Cloud API Credentials page, find the API key you created for this project, click the Actions icon, select Delete API key, and confirm in the modal.
That's a Wrap!
That's a Wrap!
Nice work! You just built and deployed a fully functional AI-powered expense tracker with React, complete with interactive charts, global state management, and live AI insights from Google Gemini.
You've learned how to:
- Build a complete React application from scratch using Vite, with useContext and useReducer for global state, React Router for multi-page navigation, and Recharts for interactive data visualization.
- Integrate Google Gemini AI into a frontend app using a custom useGemini hook with proper loading states, error handling, and structured JSON responses.
- Style a professional dark-themed UI entirely with Tailwind CSS utility classes (flex, grid, responsive breakpoints, hover/focus/disabled states, transitions, and animations) and deploy to Vercel with environment variables.
- Secret Mission: Added a budget goal system with per-category limits, color-coded progress bars, and AI-powered warnings when spending approaches or exceeds your budget.
Ready to quiz yourself?