Build a Personal Expense Tracker

Build a multi-page expense tracker app using Angular Signals and routing.

Introduction

30 Second Summary

Keeping track of where your money goes is one of those things everyone means to do but rarely sticks with. A dedicated app that totals your spending by category makes the habit effortless.

In this project, you will build a personal expense tracker using Angular. You'll create a dashboard that displays your expenses with running totals, and a form that validates input before saving.

What You'll Build

A single-page application with two views: a dashboard showing expense history and computed totals, and a form for adding new expenses with built-in validation.

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

  • A Dashboard component displaying expenses and totals powered by Angular Signals.
  • An Add Expense form with Reactive Forms validation that prevents bad data from being saved.
  • Client-side routing connecting both views with a navigation bar.
  • Secret Mission: Add a category filter to the dashboard so users can view expenses for a single category at a time.

Are there any prerequisites?

Familiarity with TypeScript, HTML, and CSS is helpful. No prior Angular experience is required.

Set Up Your Angular Project

The Angular template in Firebase Studio ships with demo content. A logo, some links, and placeholder text fill the starter page.

Before building our expense tracker, you need a blank canvas. In this step, you'll strip the starter content and replace it with a simple heading to confirm everything is wired up.

In this step, get ready to:
  • Remove the default Angular starter content.
  • Confirm the app displays your custom heading in the web preview.
Replace the starter content with a clean component

The Angular scaffold generated several files for the root component. The two you care about right now are src/app/app.html (the template) and src/app/app.ts (the component class). You'll replace both with minimal content so the app renders a single heading.

  • In the Explorer sidebar (Ctrl+Shift+E), expand src then app.
  • Open app.html and select all the existing content.
  • Replace everything with this single line:
<h1>Expense Tracker</h1>
  • Now open app.ts in the same folder.
  • Select all the existing content and replace it with this code:
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  imports: [],
  template: '<h1>Expense Tracker</h1>',
})
export class App {}

What does this code do?

This is the simplest possible Angular component. Here is what each piece does:

  • @Component is a decorator that tells Angular this class defines a component.
  • selector: 'app-root' names the custom HTML tag Angular uses to mount this component in the page.
  • template provides an inline HTML string. Angular renders this directly instead of loading a separate file.
  • imports: [] is an empty array because this component does not depend on any other Angular modules or components yet.
  • Save both files (Ctrl+S).
  • Check the web preview panel. You should see only the text "Expense Tracker" as a large heading on a blank page.

Still seeing the old starter page?

  • Make sure you saved both app.html and app.ts. Unsaved files show a dot on their tab.
  • Confirm you edited the files inside src/app/ and not a different folder.
  • If the preview is blank or shows an error, switch to the terminal and check for a compilation message. A typo in the decorator (missing comma, mismatched quotes) will prevent the build from succeeding.

Still stuck? Help me debug why my Angular app still shows the default template.

Why use an inline template here?

Angular components can define their HTML in a separate file (templateUrl) or directly in the decorator (template). For a one-line heading, inline is simpler. You will switch to a separate template file later when the HTML grows.

✔️ 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 content of each file at this point.

<h1>Expense Tracker</h1>
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  imports: [],
  template: '<h1>Expense Tracker</h1>',
})
export class App {}

Your canvas is clean and the dev server is compiling without errors. Next up, you will create the data model and a state service that powers every view in the expense tracker.

Create the Expense Model and State Service

Your Angular project is running and you can see the heading in the web preview. Now it's time to build the data layer that powers your expense tracker.

Every app needs a clear definition of its data and a centralized place to manage it. You'll create a TypeScript interface that defines what an expense looks like, then build a service that holds your expense data using Signals. Signals are Angular's built-in reactivity primitive that automatically update your UI when data changes.

In this step, get ready to:
  • Define the Expense interface and category constants.
  • Build a state service using signals for reactive data management.
  • Verify the service works by displaying the computed total in the app.
Define the Expense interface and categories

Before you can store or display expenses, your app needs a shared definition of what an expense actually looks like. A TypeScript interface gives you compile-time safety so every expense has the same shape.

  • In the Explorer sidebar, right-click the app folder (under src), select New File, and name it expense.ts.
  • Paste the following code into the new file:
export interface Expense {
  id: number;
  description: string;
  amount: number;
  category: string;
  date: string;
}

export const CATEGORIES = ['Food', 'Transport', 'Entertainment', 'Bills', 'Other'];

What does this code do?

The Expense interface defines the five fields every expense must have: a unique id, a description of what was purchased, the amount spent, a category label, and the date it occurred.

The CATEGORIES constant gives you a reusable list of category options. You'll use this array in the form dropdown and for filtering later.

  • Save expense.ts.
  • You won't see a change in the preview yet. The next file uses this interface.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

export interface Expense {
  id: number;
  description: string;
  amount: number;
  category: string;
  date: string;
}

export const CATEGORIES = ['Food', 'Transport', 'Entertainment', 'Bills', 'Other'];
Build the expense store service

Angular uses services to hold data that multiple components need to share. Your expense store will use a writable signal to hold the expense array, a computed signal to automatically calculate the total, and a method to add new expenses.

  • In the Explorer sidebar, right-click the app folder again, select New File, and name it expense-store.ts.
  • Paste the following code into the new file:
import { Injectable, signal, computed } from '@angular/core';
import { Expense } from './expense';

@Injectable({ providedIn: 'root' })
export class ExpenseStore {
  // Private writable signal holding the expense array
  private readonly _expenses = signal<Expense[]>([
    { id: 1, description: 'Grocery shopping', amount: 45.50, category: 'Food', date: '2026-06-10' },
    { id: 2, description: 'Bus pass', amount: 30.00, category: 'Transport', date: '2026-06-11' },
    { id: 3, description: 'Movie tickets', amount: 24.00, category: 'Entertainment', date: '2026-06-12' },
  ]);

  // Expose a readonly version so components can read but not overwrite
  readonly expenses = this._expenses.asReadonly();

  // Computed signal that recalculates whenever _expenses changes
  readonly total = computed(() =>
    this._expenses().reduce((sum, exp) => sum + exp.amount, 0)
  );

  // Add a new expense and update the signal
  addExpense(data: Omit<Expense, 'id'>) {
    const newExpense: Expense = {
      ...data,
      id: Date.now(),
    };
    this._expenses.update(expenses => [...expenses, newExpense]);
  }
}

What does this service do?

The @Injectable({ providedIn: 'root' }) decorator makes this a singleton service. Angular creates one instance and shares it across your entire app.

The _expenses signal holds the array of expenses. Calling .asReadonly() exposes a version that components can read but not accidentally overwrite.

The total computed signal automatically recalculates whenever the expenses array changes. The addExpense() method creates a new expense with a timestamp-based ID and appends it to the array.

Why signals instead of a state management library?

Signals are Angular's built-in reactivity primitive. They teach the fundamental mental model without adding external dependencies.

For an app of this size, a service with signals is the idiomatic approach. Libraries like NgRx are designed for much larger applications where the extra structure pays off.

  • Save expense-store.ts.
  • You won't see a change in the preview yet. The next substep wires this service into the app component.

Seeing red underlines in the editor?

If you see an error under import { Expense } from './expense', make sure you saved expense.ts in the previous substep. TypeScript can't find the interface until the file is saved.

If you see errors inside the class itself, check that the sample expense objects match the Expense interface fields exactly (id, description, amount, category, date).

Help me fix a TypeScript error in my expense store service.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

import { Injectable, signal, computed } from '@angular/core';
import { Expense } from './expense';

@Injectable({ providedIn: 'root' })
export class ExpenseStore {
  private readonly _expenses = signal<Expense[]>([
    { id: 1, description: 'Grocery shopping', amount: 45.50, category: 'Food', date: '2026-06-10' },
    { id: 2, description: 'Bus pass', amount: 30.00, category: 'Transport', date: '2026-06-11' },
    { id: 3, description: 'Movie tickets', amount: 24.00, category: 'Entertainment', date: '2026-06-12' },
  ]);

  readonly expenses = this._expenses.asReadonly();
  readonly total = computed(() =>
    this._expenses().reduce((sum, exp) => sum + exp.amount, 0)
  );

  addExpense(data: Omit<Expense, 'id'>) {
    const newExpense: Expense = {
      ...data,
      id: Date.now(),
    };
    this._expenses.update(expenses => [...expenses, newExpense]);
  }
}
Verify the service by displaying the total

The quickest way to confirm your service works is to inject it into a component and display a value on screen. You'll temporarily wire the store into the root app component to see the computed total.

  • Open src/app/app.ts in the editor.
  • Replace the entire contents of the file with the following code:
import { Component, inject } from '@angular/core';
import { ExpenseStore } from './expense-store';

@Component({
  selector: 'app-root',
  imports: [],
  template: '<h1>Expense Tracker</h1><p>Total: {{ store.total() }}</p>',
})
export class App {
  // Inject the ExpenseStore service using Angular's inject function
  protected readonly store = inject(ExpenseStore);
}

What's happening here?

The inject(ExpenseStore) function is Angular's recommended way to request a dependency. It asks Angular's dependency injection system for the single shared instance of ExpenseStore.

In the template, {{ store.total() }} reads the computed signal's current value. Angular knows to re-render this text automatically whenever the underlying expenses change.

  • Save app.ts.
  • Check the web preview. You should see the heading Expense Tracker followed by Total: 99.5 on the page.

Don't see the total in the preview?

Make sure all three files are saved: expense.ts, expense-store.ts, and app.ts. The dev server recompiles on save, but only picks up changes from saved files.

If you see a blank page or an error, check the terminal for compilation errors. A common issue is a typo in the import path. The import should be './expense-store' (no file extension, no extra slashes).

Help me debug why my total isn't showing in the Angular web preview.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

import { Component, inject } from '@angular/core';
import { ExpenseStore } from './expense-store';

@Component({
  selector: 'app-root',
  imports: [],
  template: '<h1>Expense Tracker</h1><p>Total: {{ store.total() }}</p>',
})
export class App {
  protected readonly store = inject(ExpenseStore);
}

Your data layer is working. The signal-based store holds your expenses and automatically computes the total. Next up, you'll build a proper Dashboard component to display each expense as a styled card.

Build the Dashboard Component

Your data layer is ready. The ExpenseStore service holds your expenses and computes the total. Now you need a way to display that data to users.

In Angular, components are the building blocks of the user interface. Each component owns a piece of the screen. You will create a Dashboard component that shows the expense list and the running total.

In this step, get ready to:
  • Generate a Dashboard component using the Angular CLI.
  • Build the template with a summary card, expense list, and empty state.
  • Style the dashboard with cards, badges, and a prominent total.
Generate the Dashboard component

The Angular CLI can scaffold a new component with a single command. It creates the TypeScript class, HTML template, CSS file, and test file all wired together.

  • Generate the Dashboard component by running this command in your terminal:
ng generate component dashboard

You should see output confirming that files were created inside src/app/dashboard/.

What does this command do?

The CLI creates a dashboard folder inside src/app/ with four files: dashboard.ts (component class), dashboard.html (template), dashboard.css (styles), and dashboard.spec.ts (test file).

Angular uses the 2025 style guide by default, so file names are concise (dashboard.ts instead of dashboard.component.ts) and the class is named Dashboard instead of DashboardComponent.

  • Confirm the files were created by running:
ls src/app/dashboard

You should see dashboard.css, dashboard.html, dashboard.spec.ts, and dashboard.ts listed.

Now you need to connect the Dashboard to your ExpenseStore service. The generated file has placeholder content that you will replace.

  • Open src/app/dashboard/dashboard.ts and replace its entire contents with the following code:
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { ExpenseStore } from '../expense-store';

@Component({
  selector: 'app-dashboard',
  imports: [RouterLink, CurrencyPipe, DatePipe],
  templateUrl: './dashboard.html',
  styleUrl: './dashboard.css',
})
export class Dashboard {
  protected readonly store = inject(ExpenseStore);
}

What does this code do?

The inject(ExpenseStore) call uses Angular's dependency injection to provide the singleton store instance to this component. The protected readonly access modifier means only this component and its template can read the store.

The imports array registers RouterLink (for navigation links), CurrencyPipe (for formatting amounts as currency), and DatePipe (for formatting dates) so the template can use them.

  • Save dashboard.ts.
  • Check your terminal for compilation errors. You should see no red text.

Seeing a compilation error?

Make sure the import path '../expense-store' uses two dots. The dashboard folder is one level deeper than expense-store.ts.

If you see an error about RouterLink, confirm that @angular/router is in your package.json dependencies. It should already be there from the Angular template.

Help me fix my Dashboard component compilation error.

Build the dashboard template

The component class is ready. Now you will build the HTML template that displays your expenses using Angular's modern control flow syntax.

  • Open src/app/dashboard/dashboard.html and replace its contents with the following template:
<section class="summary">
  <h2>Total Spent</h2>
  <p class="total">{{ store.total() | currency }}</p>
</section>

<section class="expenses">
  <h2>Recent Expenses</h2>

  @if (store.expenses().length === 0) {
    <p class="empty">No expenses yet. Add your first one!</p>
  }

  @for (expense of store.expenses(); track expense.id) {
    <article class="expense-card">
      <div class="expense-info">
        <h3>{{ expense.description }}</h3>
        <span class="category">{{ expense.category }}</span>
        <span class="date">{{ expense.date | date:'mediumDate' }}</span>
      </div>
      <p class="amount">{{ expense.amount | currency }}</p>
    </article>
  }
</section>

<a routerLink="/add" class="add-button">+ Add Expense</a>

What does this template do?

The @if block conditionally shows an empty-state message when there are no expenses. This is Angular's built-in control flow syntax, replacing the older *ngIf directive.

The @for block loops through each expense and renders a card. The track expense.id clause tells Angular how to efficiently identify items when the list changes.

The | currency and | date:'mediumDate' are pipes that format raw values into user-friendly strings. Calling store.total() and store.expenses() reads the signal values in the template.

  • Save dashboard.html.
  • Check your terminal. You should see no compilation errors.

Seeing a template parse error?

Make sure you have CurrencyPipe and DatePipe listed in the imports array in dashboard.ts. Without them, Angular cannot resolve the currency and date pipes in the template.

Help me fix my dashboard template error.

Style the dashboard and clean up the root component

The template structure is in place. Now you will add CSS to turn the raw HTML into polished expense cards with a prominent total summary.

  • Open src/app/dashboard/dashboard.css and replace its contents with the following styles:
.summary {
  background: #1a73e8;
  color: white;
  padding: 1.5rem;
  border-radius: 12px;
  margin-bottom: 1.5rem;
  text-align: center;
}

.summary h2 {
  margin: 0;
  font-size: 0.875rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  opacity: 0.9;
}

.total {
  font-size: 2.5rem;
  font-weight: 700;
  margin: 0.25rem 0 0;
}

.expenses h2 {
  margin: 0 0 1rem;
  font-size: 1.125rem;
}

.empty {
  color: #666;
  text-align: center;
  padding: 2rem;
}

.expense-card {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  margin-bottom: 0.75rem;
}

.expense-info h3 {
  margin: 0 0 0.25rem;
  font-size: 1rem;
}

.category {
  display: inline-block;
  background: #e8f0fe;
  color: #1a73e8;
  padding: 0.125rem 0.5rem;
  border-radius: 4px;
  font-size: 0.75rem;
  font-weight: 500;
  margin-right: 0.5rem;
}

.date {
  font-size: 0.75rem;
  color: #666;
}

.amount {
  font-size: 1.125rem;
  font-weight: 600;
  margin: 0;
  white-space: nowrap;
}

.add-button {
  display: block;
  text-align: center;
  background: #1a73e8;
  color: white;
  padding: 0.875rem;
  border-radius: 8px;
  text-decoration: none;
  font-weight: 500;
  margin-top: 1.5rem;
}

.add-button:hover {
  background: #1557b0;
}

What do these styles do?

The .summary section gets a bold blue background to draw the eye to the total. Each .expense-card uses flexbox to push the amount to the right edge.

The .category class renders a small blue badge next to each expense. Angular scopes these styles to the Dashboard component automatically, so they will not leak into other components.

  • Save dashboard.css.

The Dashboard is built, but it is not rendered yet because routing is not wired up. Before moving on, clean up the temporary total display in the root component.

  • Open src/app/app.ts and find this code:
import { Component, inject } from '@angular/core';
import { ExpenseStore } from './expense-store';

@Component({
  selector: 'app-root',
  imports: [],
  template: '<h1>Expense Tracker</h1><p>Total: {{ store.total() }}</p>',
})
export class App {
  protected readonly store = inject(ExpenseStore);
}
  • Replace the entire file with this simplified version:
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  imports: [],
  template: '<h1>Expense Tracker</h1>',
})
export class App {}

Why remove the store from app.ts?

You injected the store in the previous step as a quick test to prove it worked. Now that the Dashboard component owns the expense display, the root component no longer needs it.

In the routing step, you will wire the Dashboard to appear inside app.ts through a <router-outlet />.

  • Save app.ts.
  • Check the web preview. You should see only the heading "Expense Tracker" with the total paragraph removed.

Still seeing the total in the preview?

Make sure you saved app.ts after replacing its contents. The dev server hot-reloads on save.

If the preview shows a blank page, check the terminal for errors. You may have accidentally deleted too much from the file.

Help me fix my app.ts after removing the store.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three dashboard files and app.ts.

ⓧ I'd like to double check the full code

Here is the complete code for each file at the end of this step.

import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { ExpenseStore } from '../expense-store';

@Component({
  selector: 'app-dashboard',
  imports: [RouterLink, CurrencyPipe, DatePipe],
  templateUrl: './dashboard.html',
  styleUrl: './dashboard.css',
})
export class Dashboard {
  protected readonly store = inject(ExpenseStore);
}
<section class="summary">
  <h2>Total Spent</h2>
  <p class="total">{{ store.total() | currency }}</p>
</section>

<section class="expenses">
  <h2>Recent Expenses</h2>

  @if (store.expenses().length === 0) {
    <p class="empty">No expenses yet. Add your first one!</p>
  }

  @for (expense of store.expenses(); track expense.id) {
    <article class="expense-card">
      <div class="expense-info">
        <h3>{{ expense.description }}</h3>
        <span class="category">{{ expense.category }}</span>
        <span class="date">{{ expense.date | date:'mediumDate' }}</span>
      </div>
      <p class="amount">{{ expense.amount | currency }}</p>
    </article>
  }
</section>

<a routerLink="/add" class="add-button">+ Add Expense</a>
.summary {
  background: #1a73e8;
  color: white;
  padding: 1.5rem;
  border-radius: 12px;
  margin-bottom: 1.5rem;
  text-align: center;
}

.summary h2 {
  margin: 0;
  font-size: 0.875rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  opacity: 0.9;
}

.total {
  font-size: 2.5rem;
  font-weight: 700;
  margin: 0.25rem 0 0;
}

.expenses h2 {
  margin: 0 0 1rem;
  font-size: 1.125rem;
}

.empty {
  color: #666;
  text-align: center;
  padding: 2rem;
}

.expense-card {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  margin-bottom: 0.75rem;
}

.expense-info h3 {
  margin: 0 0 0.25rem;
  font-size: 1rem;
}

.category {
  display: inline-block;
  background: #e8f0fe;
  color: #1a73e8;
  padding: 0.125rem 0.5rem;
  border-radius: 4px;
  font-size: 0.75rem;
  font-weight: 500;
  margin-right: 0.5rem;
}

.date {
  font-size: 0.75rem;
  color: #666;
}

.amount {
  font-size: 1.125rem;
  font-weight: 600;
  margin: 0;
  white-space: nowrap;
}

.add-button {
  display: block;
  text-align: center;
  background: #1a73e8;
  color: white;
  padding: 0.875rem;
  border-radius: 8px;
  text-decoration: none;
  font-weight: 500;
  margin-top: 1.5rem;
}

.add-button:hover {
  background: #1557b0;
}
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  imports: [],
  template: '<h1>Expense Tracker</h1>',
})
export class App {}

Your Dashboard component is built and ready to render. Next up, you will build the Add Expense form so users can submit new entries.

Build the Add Expense Form

Your dashboard can display expenses, but users need a way to add new ones. This is where forms come in.

In this step, you will build a form component with validation that ensures only clean data enters your expense list. Angular's Reactive Forms module gives you full programmatic control over form fields, their validation rules, and the submission flow.

In this step, get ready to:
  • Generate the AddExpense component and define a validated form group.
  • Build the form template with data bindings and inline error messages.
  • Implement form submission that saves the expense and navigates back to the dashboard.
Generate the AddExpense component and define the form

A FormGroup bundles multiple form controls together and tracks their collective validity. You will define one with four controls, each carrying its own validation rules.

  • Generate the AddExpense component by running this command in the terminal:
ng generate component add-expense

What does this command do?

Angular CLI creates a new folder at src/app/add-expense/ containing three files: add-expense.ts (the component class), add-expense.html (the template), and add-expense.css (the styles). It also creates a .spec.ts test file.

  • Open src/app/add-expense/add-expense.ts and replace its contents with the following code:
import { Component, inject } from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { ExpenseStore } from '../expense-store';
import { CATEGORIES } from '../expense';

@Component({
  selector: 'app-add-expense',
  imports: [ReactiveFormsModule],
  templateUrl: './add-expense.html',
  styleUrl: './add-expense.css',
})
export class AddExpense {
  private readonly store = inject(ExpenseStore);

  protected readonly categories = CATEGORIES;

  // Define the form with validation rules for each field
  protected readonly form = new FormGroup({
    description: new FormControl('', [Validators.required, Validators.minLength(3)]),
    amount: new FormControl<number | null>(null, [Validators.required, Validators.min(0.01)]),
    category: new FormControl('', Validators.required),
    date: new FormControl(new Date().toISOString().split('T')[0], Validators.required),
  });
}

What does this code do?

ReactiveFormsModule is added to the component's imports array so that Angular recognizes form directives like formGroup and formControlName in the template.

Each FormControl receives a default value and one or more validators. For example, description requires at least 3 characters, and amount must be greater than 0.01. The date control defaults to today's date using JavaScript's toISOString() method.

  • Save add-expense.ts.

You won't see a change in the preview yet. The component needs a template and routing to be visible.

Seeing a compilation error?

Check that your import paths use '../expense-store' and '../expense' (with ../ since the component lives one folder deeper).

help me fix my add-expense component import errors

Build the form template with validation messages

The template binds each HTML input to its corresponding FormControl using formControlName. Angular's @if control flow shows error messages only when a field is invalid and the user has interacted with it.

  • Open src/app/add-expense/add-expense.html and replace its contents with the following template:
<h2>Add New Expense</h2>

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <div class="form-field">
    <label for="description">Description</label>
    <input id="description" type="text" formControlName="description" placeholder="What did you spend on?" />
    @if (form.controls.description.invalid && form.controls.description.touched) {
      <span class="error">Description must be at least 3 characters.</span>
    }
  </div>

  <div class="form-field">
    <label for="amount">Amount</label>
    <input id="amount" type="number" formControlName="amount" placeholder="0.00" step="0.01" />
    @if (form.controls.amount.invalid && form.controls.amount.touched) {
      <span class="error">Enter a valid amount greater than 0.</span>
    }
  </div>

  <div class="form-field">
    <label for="category">Category</label>
    <select id="category" formControlName="category">
      <option value="" disabled>Select a category</option>
      @for (cat of categories; track cat) {
        <option [value]="cat">{{ cat }}</option>
      }
    </select>
    @if (form.controls.category.invalid && form.controls.category.touched) {
      <span class="error">Please select a category.</span>
    }
  </div>

  <div class="form-field">
    <label for="date">Date</label>
    <input id="date" type="date" formControlName="date" />
    @if (form.controls.date.invalid && form.controls.date.touched) {
      <span class="error">Please select a date.</span>
    }
  </div>

  <button type="submit" [disabled]="form.invalid">Add Expense</button>
</form>

<a routerLink="/" class="back-link">Cancel</a>

What does this template do?

[formGroup]="form" connects the entire <form> element to the FormGroup you defined in the class. Each formControlName links an input to a specific control inside that group.

The @if blocks check two conditions: .invalid (the validator rule failed) and .touched (the user clicked into the field and left). This prevents error messages from showing before the user has had a chance to type.

The @for loop renders one <option> per category from the CATEGORIES array you imported in the component class.

  • Now open src/app/add-expense/add-expense.css and replace its contents with these styles:
h2 {
  margin: 0 0 1.5rem;
}

.form-field {
  margin-bottom: 1.25rem;
}

.form-field label {
  display: block;
  font-size: 0.875rem;
  font-weight: 500;
  margin-bottom: 0.375rem;
  color: #333;
}

.form-field input,
.form-field select {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #ccc;
  border-radius: 8px;
  font-size: 1rem;
  box-sizing: border-box;
}

.form-field input:focus,
.form-field select:focus {
  outline: none;
  border-color: #1a73e8;
  box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
}

.error {
  display: block;
  color: #d93025;
  font-size: 0.75rem;
  margin-top: 0.25rem;
}

button[type="submit"] {
  width: 100%;
  padding: 0.875rem;
  background: #1a73e8;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  font-weight: 500;
  cursor: pointer;
  margin-top: 0.5rem;
}

button[type="submit"]:hover:not(:disabled) {
  background: #1557b0;
}

button[type="submit"]:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.back-link {
  display: block;
  text-align: center;
  margin-top: 1rem;
  color: #1a73e8;
  text-decoration: none;
}

.back-link:hover {
  text-decoration: underline;
}

What do these styles do?

The :focus rules add a blue border and subtle glow when a field is selected, giving users clear feedback about which input is active.

The .error class styles validation messages in red below their fields. The submit button uses a :disabled state that grays it out until all validation rules pass.

  • Save both add-expense.html and add-expense.css.

You won't see the form in the preview yet because routing has not been set up. The next substep completes the component logic.

Seeing template parse errors?

Make sure ReactiveFormsModule is in the component's imports array. Without it, Angular does not recognize formGroup or formControlName directives.

help me debug my Angular form template errors

Implement form submission and navigation

When the user submits a valid form, the component should save the expense to the store, clear the form fields, and send the user back to the dashboard. Angular's Router service handles programmatic navigation.

  • Return to src/app/add-expense/add-expense.ts and replace the entire file with this updated version that adds the Router import and the onSubmit() method:
import { Component, inject } from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ExpenseStore } from '../expense-store';
import { CATEGORIES } from '../expense';

@Component({
  selector: 'app-add-expense',
  imports: [ReactiveFormsModule],
  templateUrl: './add-expense.html',
  styleUrl: './add-expense.css',
})
export class AddExpense {
  private readonly store = inject(ExpenseStore);
  private readonly router = inject(Router);

  protected readonly categories = CATEGORIES;

  // Define the form with validation rules for each field
  protected readonly form = new FormGroup({
    description: new FormControl('', [Validators.required, Validators.minLength(3)]),
    amount: new FormControl<number | null>(null, [Validators.required, Validators.min(0.01)]),
    category: new FormControl('', Validators.required),
    date: new FormControl(new Date().toISOString().split('T')[0], Validators.required),
  });

  // Submit the form, save to store, and navigate back
  onSubmit() {
    if (this.form.valid) {
      const { description, amount, category, date } = this.form.getRawValue();
      this.store.addExpense({
        description: description!,
        amount: amount!,
        category: category!,
        date: date!,
      });
      this.form.reset();
      this.router.navigate(['/']);
    }
  }
}

What does the onSubmit method do?

this.form.valid is a guard that prevents submission if any control fails its validators. This matches the disabled button in the template, adding a second layer of safety.

getRawValue() extracts the current values from all controls. The non-null assertions (!) are safe here because the if (this.form.valid) guard guarantees all required fields have values.

After saving, this.form.reset() clears all fields and this.router.navigate(['/']) sends the user back to the dashboard route.

  • Save add-expense.ts.

The component is complete. You will test the full add-expense flow once routing connects both views in the next step.

Getting a Router injection error?

If you see an error about Router not being provided, this is expected until you configure routing in the next step. The error will resolve once provideRouter is added to your app config.

help me understand my Angular Router injection error

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in src/app/add-expense/.

ⓧ I'd like to double check the full code

Here is the complete code for each file in the add-expense component:

import { Component, inject } from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ExpenseStore } from '../expense-store';
import { CATEGORIES } from '../expense';

@Component({
  selector: 'app-add-expense',
  imports: [ReactiveFormsModule],
  templateUrl: './add-expense.html',
  styleUrl: './add-expense.css',
})
export class AddExpense {
  private readonly store = inject(ExpenseStore);
  private readonly router = inject(Router);

  protected readonly categories = CATEGORIES;

  protected readonly form = new FormGroup({
    description: new FormControl('', [Validators.required, Validators.minLength(3)]),
    amount: new FormControl<number | null>(null, [Validators.required, Validators.min(0.01)]),
    category: new FormControl('', Validators.required),
    date: new FormControl(new Date().toISOString().split('T')[0], Validators.required),
  });

  onSubmit() {
    if (this.form.valid) {
      const { description, amount, category, date } = this.form.getRawValue();
      this.store.addExpense({
        description: description!,
        amount: amount!,
        category: category!,
        date: date!,
      });
      this.form.reset();
      this.router.navigate(['/']);
    }
  }
}
<h2>Add New Expense</h2>

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <div class="form-field">
    <label for="description">Description</label>
    <input id="description" type="text" formControlName="description" placeholder="What did you spend on?" />
    @if (form.controls.description.invalid && form.controls.description.touched) {
      <span class="error">Description must be at least 3 characters.</span>
    }
  </div>

  <div class="form-field">
    <label for="amount">Amount</label>
    <input id="amount" type="number" formControlName="amount" placeholder="0.00" step="0.01" />
    @if (form.controls.amount.invalid && form.controls.amount.touched) {
      <span class="error">Enter a valid amount greater than 0.</span>
    }
  </div>

  <div class="form-field">
    <label for="category">Category</label>
    <select id="category" formControlName="category">
      <option value="" disabled>Select a category</option>
      @for (cat of categories; track cat) {
        <option [value]="cat">{{ cat }}</option>
      }
    </select>
    @if (form.controls.category.invalid && form.controls.category.touched) {
      <span class="error">Please select a category.</span>
    }
  </div>

  <div class="form-field">
    <label for="date">Date</label>
    <input id="date" type="date" formControlName="date" />
    @if (form.controls.date.invalid && form.controls.date.touched) {
      <span class="error">Please select a date.</span>
    }
  </div>

  <button type="submit" [disabled]="form.invalid">Add Expense</button>
</form>

<a routerLink="/" class="back-link">Cancel</a>
h2 {
  margin: 0 0 1.5rem;
}

.form-field {
  margin-bottom: 1.25rem;
}

.form-field label {
  display: block;
  font-size: 0.875rem;
  font-weight: 500;
  margin-bottom: 0.375rem;
  color: #333;
}

.form-field input,
.form-field select {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #ccc;
  border-radius: 8px;
  font-size: 1rem;
  box-sizing: border-box;
}

.form-field input:focus,
.form-field select:focus {
  outline: none;
  border-color: #1a73e8;
  box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
}

.error {
  display: block;
  color: #d93025;
  font-size: 0.75rem;
  margin-top: 0.25rem;
}

button[type="submit"] {
  width: 100%;
  padding: 0.875rem;
  background: #1a73e8;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  font-weight: 500;
  cursor: pointer;
  margin-top: 0.5rem;
}

button[type="submit"]:hover:not(:disabled) {
  background: #1557b0;
}

button[type="submit"]:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.back-link {
  display: block;
  text-align: center;
  margin-top: 1rem;
  color: #1a73e8;
  text-decoration: none;
}

.back-link:hover {
  text-decoration: underline;
}

Your form component is ready to accept user input. Next up, you will wire both the Dashboard and AddExpense components together with routing so users can navigate between views.

Add Routing and Navigation

You now have two fully built components: a dashboard that displays expenses and a form that collects new ones. The missing piece is a way to switch between them without reloading the page.

Angular's Router maps URL paths to components. When a user clicks a navigation link, the router swaps out the visible component without a full page refresh. In this step, you will define two routes and build a navigation shell that ties the entire app together.

In this step, get ready to:
  • Define routes that map URL paths to your Dashboard and AddExpense components.
  • Build a navigation header with links that highlight the active page.
  • Test the full end-to-end flow of adding an expense and seeing it on the dashboard.
Define the route configuration

Routes tell Angular which component to display for each URL path. You need two routes: the root path for the dashboard, and /add for the expense form.

  • Create a new file called app.routes.ts inside the src/app/ folder.
  • Add the following route configuration:
import { Routes } from '@angular/router';
import { Dashboard } from './dashboard/dashboard';
import { AddExpense } from './add-expense/add-expense';

// Define which component renders at each URL path
export const routes: Routes = [
  { path: '', component: Dashboard, title: 'Dashboard - Expense Tracker' },
  { path: 'add', component: AddExpense, title: 'Add Expense - Expense Tracker' },
];

What does this code do?

Each route object maps a path to a component. The empty string '' means the root URL (localhost:4200/) loads the Dashboard.

The title property automatically updates the browser tab title when the route activates. No extra code needed.

  • Save app.routes.ts.
  • You won't see a visible change yet. The next substep wires these routes into the app component.
Build the app shell with navigation

The root app component needs to become a shell that holds the navigation bar and a router-outlet where Angular swaps in the active route's component. You will update three files: the TypeScript class, the HTML template, and the stylesheet.

  • Open src/app/app.ts and replace its entire contents with the following:
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, RouterLink, RouterLinkActive],
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}

What does this code do?

The component now imports three router directives. RouterOutlet is the placeholder where the matched route's component renders. RouterLink turns regular anchor tags into client-side navigation links.

RouterLinkActive automatically applies a CSS class to the link whose route is currently active. This lets you highlight the current page in the nav bar.

  • Save app.ts. The dev server will show a compilation error because the template and style files do not exist yet. That is expected.
  • Create a new file called app.html inside src/app/ and add the following template:
<header>
  <h1>Expense Tracker</h1>
  <nav>
    <a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Dashboard</a>
    <a routerLink="/add" routerLinkActive="active">Add Expense</a>
  </nav>
</header>

<main>
  <router-outlet />
</main>

What does this template do?

The routerLink directive on each anchor navigates to the specified path without reloading the page. routerLinkActive="active" adds the CSS class active to whichever link matches the current URL.

The [routerLinkActiveOptions]="{ exact: true }" on the Dashboard link ensures it only highlights when the URL is exactly /. Without it, the Dashboard link would also highlight on /add because that path starts with /.

  • Save app.html. One more file to go before the app compiles.
  • Create a new file called app.css inside src/app/ and add the following styles:
header {
  background: white;
  padding: 1rem 1.5rem;
  border-bottom: 1px solid #e0e0e0;
  margin-bottom: 1.5rem;
}

header h1 {
  margin: 0 0 0.75rem;
  font-size: 1.25rem;
  color: #1a73e8;
}

nav {
  display: flex;
  gap: 1rem;
}

nav a {
  text-decoration: none;
  color: #666;
  font-size: 0.875rem;
  font-weight: 500;
  padding: 0.375rem 0;
  border-bottom: 2px solid transparent;
}

nav a.active {
  color: #1a73e8;
  border-bottom-color: #1a73e8;
}

main {
  padding: 0 1.5rem 2rem;
  max-width: 600px;
  margin: 0 auto;
}

What do these styles do?

The header gets a clean bottom border to separate navigation from page content. Nav links are styled as text with a transparent bottom border that turns blue when the .active class is applied by the router.

The main element constrains content to 600px and centers it. This keeps the expense cards readable without stretching across wide screens.

  • Save app.css.
  • Check the web preview. You should see an "Expense Tracker" heading in blue, with "Dashboard" and "Add Expense" links below it. The Dashboard link should have a blue underline indicating it is active.
  • Below the nav, you should see the dashboard content: the Total Spent card and your sample expense list.

Not seeing the navigation?

Make sure all three files (app.ts, app.html, app.css) are saved inside src/app/. The app will not compile until all three exist.

Check the terminal for compilation errors. A common mistake is a typo in templateUrl or styleUrl that points to the wrong filename.

help me fix a compilation error in my app component

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete code for all the files you created or modified in this substep.

import { Routes } from '@angular/router';
import { Dashboard } from './dashboard/dashboard';
import { AddExpense } from './add-expense/add-expense';

export const routes: Routes = [
  { path: '', component: Dashboard, title: 'Dashboard - Expense Tracker' },
  { path: 'add', component: AddExpense, title: 'Add Expense - Expense Tracker' },
];
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, RouterLink, RouterLinkActive],
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<header>
  <h1>Expense Tracker</h1>
  <nav>
    <a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Dashboard</a>
    <a routerLink="/add" routerLinkActive="active">Add Expense</a>
  </nav>
</header>

<main>
  <router-outlet />
</main>
header {
  background: white;
  padding: 1rem 1.5rem;
  border-bottom: 1px solid #e0e0e0;
  margin-bottom: 1.5rem;
}

header h1 {
  margin: 0 0 0.75rem;
  font-size: 1.25rem;
  color: #1a73e8;
}

nav {
  display: flex;
  gap: 1rem;
}

nav a {
  text-decoration: none;
  color: #666;
  font-size: 0.875rem;
  font-weight: 500;
  padding: 0.375rem 0;
  border-bottom: 2px solid transparent;
}

nav a.active {
  color: #1a73e8;
  border-bottom-color: #1a73e8;
}

main {
  padding: 0 1.5rem 2rem;
  max-width: 600px;
  margin: 0 auto;
}
Test the full app flow

With routing in place, your two components are now connected. Time to verify the entire user journey works end-to-end.

  • In the web preview, click the **Add Expense** link in the navigation bar.
  • Confirm the form appears with fields for description, amount, category, and date.
  • Fill in the form with test values. Enter Coffee for description, 5.50 for amount, select Food as the category, and leave the date as today.
  • Click **Add Expense** to submit the form.
  • Confirm the app navigates back to the dashboard automatically.
  • Check that your new "Coffee" expense appears in the list and the Total Spent has increased.

Why does the state persist across navigation?

The ExpenseStore service is provided at the root level (providedIn: 'root'), which means Angular creates a single instance shared across the entire app. When the AddExpense component calls store.addExpense(), it updates the same signal that the Dashboard reads.

This is the power of dependency injection combined with signals. The state lives in one place, and any component that injects the service automatically sees the latest data.

Form submits but expense does not appear?

Open the browser console and look for errors. If you see a navigation error, make sure the route path in app.routes.ts uses an empty string '' for the dashboard (not '/').

If the form resets but you stay on the same page, check that this.router.navigate(['/']) is called after this.form.reset() in your add-expense.ts file.

help me debug why my expense form is not saving data

Your expense tracker is fully functional. You can navigate between views, add expenses through a validated form, and see the totals update in real time. Next up, a secret mission to add filtering to your dashboard.

Secret mission

Add Category Filtering

Your dashboard shows every expense at once. What if you only want to see how much you spent on Food this month? Add a category filter that lets users view expenses for a single category at a time, with the total updating dynamically.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely in Firebase Studio at no cost. Your workspace persists in the cloud until you choose to remove it.

Firebase Studio is sunsetting

Firebase Studio shuts down on March 22, 2027. New workspace creation is disabled after June 22, 2026.

Consider exporting your code to GitHub for long-term access before the shutdown date.

Resources you used:

  • Firebase Studio workspace ("expense-tracker").

Keep everything running

No action needed. Choose this if you want to keep experimenting with Angular features or extend the expense tracker.

  • Your workspace stays available at studio.firebase.google.com until the service shuts down on March 22, 2027.
  • The dev server stops if the workspace goes idle, but you can restart it anytime with npm start.

Pause - I'll come back to this later

Stop the running dev server to free up workspace resources. Your files remain saved in the cloud.

  • In the terminal, press Ctrl+C to stop the dev server.
  • Close the browser tab. Your workspace will be available from the Firebase Studio homepage when you return.

Delete - I don't want to use this again

Remove the workspace entirely. This permanently deletes all files and configuration.

  • Go to the Firebase Studio homepage at studio.firebase.google.com.
  • Find your "expense-tracker" workspace in the list.
  • Open Settings for the workspace and select Delete Workspace.
  • Confirm the deletion when prompted.

Export your code first

If you want to keep your code, use Firebase Studio's built-in export to push your project to a GitHub repository before deleting the workspace.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional expense tracker application using Angular's core patterns.

You've learned how to:

  • Build a multi-page Angular application with standalone components, a shared service using dependency injection, and client-side routing connecting multiple views.
  • Manage reactive state with Angular Signals (signal and computed) to keep your UI in sync with your data automatically.
  • Handle user input with Reactive Forms including validation, and render dynamic content using modern template syntax (@if and @for).
  • Secret Mission: Added dynamic category filtering to the dashboard using computed signals that derive a filtered list from a writable signal.

Ready to quiz yourself?