Expense Tracker Project
Build a personal expense tracker with Svelte featuring reactive updates, category filters, local storage, and financial summaries
Time to implement the project: ~ 12-18 hours
- Svelte Components
- Writable Stores
- Reactive Statements
- Two-Way Binding
- Local Storage
- Filtering
- Derived Values
- Responsive UI
In this beginner-level Svelte project, you will build a responsive Expense Tracker application that helps users record their daily spending and organize transactions into categories. The interface should allow users to add expenses, edit or remove existing entries, filter transactions, and immediately see updated totals without manually refreshing the page. Every interaction should demonstrate Svelte's reactive nature and provide instant visual feedback.
Unlike a simple static CRUD exercise, this project introduces practical frontend patterns that appear in real financial dashboards and productivity tools. You will organize the application into reusable Svelte components, manage shared state with writable stores, persist information using localStorage, and calculate summaries automatically through reactive declarations. By the end of the project, you will have a polished application that showcases both technical skills and thoughtful user experience.
Project Goal and Learning Objectives
The primary objective of this project is to teach you how Svelte handles reactivity without requiring complex state management libraries. Every time a user adds a new transaction, edits an amount, or deletes an expense, the interface should update automatically. This allows you to understand one of Svelte's biggest strengths: writing straightforward code while still producing highly dynamic interfaces.
Throughout the implementation, you will practice organizing business logic into maintainable components rather than placing everything inside one file. For example, the application can include dedicated components for the transaction form, expense list, summary cards, category filters, and statistics panel. This modular structure makes future maintenance significantly easier.
You will also gain experience calculating derived values such as monthly totals, category breakdowns, and remaining budget estimates. Instead of updating these values manually, Svelte's reactive statements can recompute them whenever the underlying data changes, resulting in cleaner code and fewer synchronization bugs.
Recommended Knowledge Before Starting
This project is designed for developers who already understand basic HTML, CSS, and JavaScript and have completed an introductory Svelte tutorial. You should know how components work, how variables are declared, and how simple event handlers are attached. Advanced topics such as routing, server-side rendering, or external databases are not required for this exercise.
The focus should remain on mastering Svelte fundamentals through practical implementation rather than building unnecessary complexity. A clean architecture with predictable reactive behavior is much more valuable than trying to add dozens of unfinished features.
- Basic understanding of Svelte components and file structure
- Knowledge of reactive variables and template syntax
- Ability to work with writable stores for shared application state
- Familiarity with JavaScript arrays, objects, and event handling
- Basic CSS skills for responsive layouts and forms
- General understanding of browser localStorage APIs
Core Features of the Expense Tracker
The application should function as a practical budgeting tool rather than a simple demo. Users should be able to manage expenses comfortably, identify spending patterns, and trust that every update is reflected immediately throughout the interface.
| Feature | Implementation Focus |
| Add transactions | Create a form that allows users to enter an expense title, amount, category, and optional notes with validation for required fields. |
| Edit and delete entries | Allow existing transactions to be modified or removed while keeping the overall totals synchronized automatically. |
| Reactive financial summary | Display total expenses, category totals, and transaction counts that update instantly whenever the data changes. |
| Category filtering | Provide controls that let users view only selected categories such as Food, Travel, Shopping, or Utilities. |
| Writable store management | Use Svelte stores to centralize transaction data and share it across multiple components without prop drilling. |
| Persistent storage | Save all transactions in localStorage so data remains available after the browser is refreshed. |
| Responsive interface | Ensure forms, tables, and summary cards remain easy to use on desktop, tablet, and mobile devices. |
| Automatic recalculation | Use reactive declarations so totals and filtered results always stay synchronized with the underlying data. |
Implementation Guidance for Beginner Svelte Developers
Begin by designing the transaction data model before writing the interface. Each expense should contain a stable identifier, description, category, amount, and creation date. Establishing this structure early makes filtering, editing, and persistence much easier later in development.
Split the application into small reusable components instead of building everything inside App.svelte. This encourages good habits from the beginning and demonstrates how Svelte projects are typically organized in production environments. Keeping responsibilities separated also makes debugging substantially easier.
Make extensive use of Svelte's reactive capabilities rather than recalculating values manually. Let totals, filtered lists, and statistics derive naturally from the source data so that the interface always stays consistent without additional bookkeeping logic.
- Create a dedicated writable store to hold all transaction data
- Keep validation logic inside the form component before updating shared state
- Persist changes to localStorage whenever the transaction list changes
- Use reactive statements instead of manually updating totals after each operation
- Separate presentation components from business logic wherever possible
- Test filtering with empty categories and large transaction lists
- Verify that editing and deleting entries updates every summary correctly
- Optimize the layout for smaller mobile screens with clear spacing and readable typography
Common Mistakes When Building an Expense Tracker Project
1. Storing expense amounts as strings and calculating totals from display values
An expense tracker is a financial interface, so the way you store numbers matters. A common beginner mistake is storing amounts exactly as users typed them, such as
"12.50", "$12.50", or "12,50". This seems harmless while the app only displays one expense, but it becomes fragile when you
calculate totals, category summaries, monthly spending, averages, or remaining budget.
Display formatting and calculation should be separate. The app should store amounts as numbers, preferably in the smallest practical unit such as cents, and format them only when rendering the UI. This prevents parsing bugs, rounding issues, and incorrect totals when users add many transactions.
This is especially important in Svelte because reactive statements make recalculation very easy. If the source data is clean, derived totals stay reliable. If the source data is a mix of formatted strings and numbers, every reactive calculation becomes risky.
Problematic approach:
const expense = {
id: "1",
title: "Groceries",
amount: "$42.90",
category: "Food"
};
$: total = expenses.reduce((sum, expense) => {
return sum + Number(expense.amount.replace("$", ""));
}, 0);
This forces every calculation to clean the value before using it. It also breaks easily if the format changes.
Better data model:
type ExpenseCategory =
| "Food"
| "Transport"
| "Shopping"
| "Utilities"
| "Health"
| "Other";
type Expense = {
id: string;
title: string;
amountCents: number;
category: ExpenseCategory;
createdAt: string;
note?: string;
};
Safe amount parsing:
function parseAmountToCents(value: string): number | null {
const normalizedValue = value.trim().replace(",", ".");
const amount = Number(normalizedValue);
if (!Number.isFinite(amount) || amount <= 0) {
return null;
}
return Math.round(amount * 100);
}
function formatMoney(amountCents: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
}).format(amountCents / 100);
}
Svelte reactive total:
$: totalExpenseCents = expenses.reduce((sum, expense) => {
return sum + expense.amountCents;
}, 0);
$: formattedTotal = formatMoney(totalExpenseCents);
Pay attention to: Store financial values as clean numbers and format them only for display. This keeps totals, category summaries, and budget calculations accurate.
2. Manually updating summary cards instead of deriving them from expenses
Expense trackers often show total spending, transaction count, category totals, largest expense, or remaining budget. A common mistake is updating these values manually whenever an expense is added, edited, or deleted. This creates synchronization bugs because every action must remember to update every related summary.
Svelte is very good at derived values. Instead of storing totals as separate source state, calculate them from the expense list. When the list changes, the summary updates automatically. This is one of the main learning goals of the project: understanding how reactive declarations can replace unnecessary bookkeeping.
For example, if a user edits an expense from $20 to $35, the total should change automatically. If a user deletes all transactions in the
Transport category, that category total should disappear or become zero without manual cleanup logic.
Problematic approach:
let expenses = [];
let total = 0;
let foodTotal = 0;
function addExpense(expense) {
expenses = [...expenses, expense];
total = total + expense.amountCents;
if (expense.category === "Food") {
foodTotal = foodTotal + expense.amountCents;
}
}
This works only while adding expenses. Editing or deleting expenses can easily leave totals outdated.
Better derived summaries:
$: totalExpenseCents = expenses.reduce((sum, expense) => {
return sum + expense.amountCents;
}, 0);
$: transactionCount = expenses.length;
$: largestExpense = expenses.reduce((largest, expense) => {
if (!largest || expense.amountCents > largest.amountCents) {
return expense;
}
return largest;
}, null);
Category summary:
function createCategoryTotals(expenses: Expense[]) {
return expenses.reduce<Record<ExpenseCategory, number>>((totals, expense) => {
totals[expense.category] = (totals[expense.category] || 0) + expense.amountCents;
return totals;
}, {} as Record<ExpenseCategory, number>);
}
$: categoryTotals = createCategoryTotals(expenses);
Rendering summary cards:
<section class="summary-grid" aria-label="Expense summary">
<SummaryCard
title="Total expenses"
value={formatMoney(totalExpenseCents)}
/>
<SummaryCard
title="Transactions"
value={String(transactionCount)}
/>
<SummaryCard
title="Largest expense"
value={largestExpense ? formatMoney(largestExpense.amountCents) : "$0.00"}
/>
</section>
Pay attention to: Totals and summaries should be derived from expenses. Do not maintain separate total state unless it truly represents independent data.
3. Writing localStorage logic directly inside every component
Local persistence is an important feature because users expect their transactions to remain after refreshing the page. A common mistake is putting
localStorage.getItem and localStorage.setItem directly inside several components. This makes persistence scattered, difficult to test, and
easy to break when the data structure changes.
A cleaner Svelte implementation should centralize expense state in a writable store. The store can handle loading from localStorage, saving updates, and exposing
actions such as addExpense, editExpense, and deleteExpense. Components should not need to know how persistence works. They should
call store actions and render store values.
This separation also makes it easier to replace localStorage later. If the project grows into a Firebase or API-backed app, most components can stay the same because the persistence details are hidden behind the store.
Problematic approach:
<script>
let expenses = JSON.parse(localStorage.getItem("expenses") || "[]");
function addExpense(expense) {
expenses = [...expenses, expense];
localStorage.setItem("expenses", JSON.stringify(expenses));
}
function deleteExpense(id) {
expenses = expenses.filter((expense) => expense.id !== id);
localStorage.setItem("expenses", JSON.stringify(expenses));
}
</script>
This works in one component, but it becomes messy when several components need to update the same expense list.
Better Svelte store:
import { writable } from "svelte/store";
const STORAGE_KEY = "expense-tracker-expenses";
function loadExpenses(): Expense[] {
try {
const savedExpenses = localStorage.getItem(STORAGE_KEY);
if (!savedExpenses) {
return [];
}
const parsed: unknown = JSON.parse(savedExpenses);
return Array.isArray(parsed) ? parsed.filter(isExpense) : [];
} catch {
return [];
}
}
function createExpenseStore() {
const { subscribe, set, update } = writable<Expense[]>(loadExpenses());
subscribe((expenses) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(expenses));
});
return {
subscribe,
add: (expense: Expense) => {
update((expenses) => [expense, ...expenses]);
},
edit: (updatedExpense: Expense) => {
update((expenses) => {
return expenses.map((expense) => {
return expense.id === updatedExpense.id ? updatedExpense : expense;
});
});
},
remove: (expenseId: string) => {
update((expenses) => {
return expenses.filter((expense) => expense.id !== expenseId);
});
},
reset: () => set([])
};
}
export const expensesStore = createExpenseStore();
Component usage:
<script lang="ts">
import { expensesStore } from "./stores/expensesStore";
function handleDelete(expenseId: string) {
expensesStore.remove(expenseId);
}
</script>
{#each $expensesStore as expense (expense.id)}
<ExpenseListItem
{expense}
on:delete={() => handleDelete(expense.id)}
/>
{/each}
Pay attention to: Keep persistence inside a store or dedicated utility. Components should render expenses and call actions, not manage localStorage details.
4. Filtering expenses by mutating the original transaction list
Category filters are useful, but they should not destroy the source data. A common mistake is filtering by replacing the original expense array with only matching transactions. The UI looks correct for the selected category, but when the user clears the filter, the removed transactions are gone unless you manually reload them from storage.
Filtering should be derived UI state. The original expense list should remain unchanged, while a reactive statement calculates which expenses are visible based on selected category, search query, date range, or amount range. This keeps the app predictable and prevents accidental data loss.
This is also important for summary cards. You need to decide whether summaries show all expenses or only filtered expenses. Both are valid, but the decision should be intentional. For example, a category filter might show filtered list totals while the main dashboard total still shows all-time spending.
Problematic approach:
function filterByCategory(category) {
expenses = expenses.filter((expense) => {
return expense.category === category;
});
}
This permanently removes all expenses outside the selected category from the current state.
Better filter state:
let selectedCategory: ExpenseCategory | "all" = "all";
let searchQuery = "";
function matchesExpenseFilters(expense: Expense): boolean {
const matchesCategory =
selectedCategory === "all" || expense.category === selectedCategory;
const normalizedQuery = searchQuery.trim().toLowerCase();
const matchesSearch =
!normalizedQuery ||
expense.title.toLowerCase().includes(normalizedQuery) ||
expense.note?.toLowerCase().includes(normalizedQuery);
return matchesCategory && matchesSearch;
}
$: visibleExpenses = $expensesStore.filter(matchesExpenseFilters);
Filtered totals:
$: visibleTotalCents = visibleExpenses.reduce((sum, expense) => {
return sum + expense.amountCents;
}, 0);
$: isFiltering =
selectedCategory !== "all" || searchQuery.trim().length > 0;
Helpful empty state:
{#if visibleExpenses.length === 0 && isFiltering}
<EmptyState
title="No expenses match these filters"
description="Try another category or clear the search field."
/>
{:else if visibleExpenses.length === 0}
<EmptyState
title="No expenses yet"
description="Add your first transaction to start tracking spending."
/>
{/if}
Pay attention to: Filters should change what is displayed, not what is stored. Keep the original transaction list intact and calculate visible expenses reactively.
5. Allowing invalid expense form submissions
Expense forms seem simple, but they need validation. Users should not be able to add an empty title, a negative amount, a zero amount, an invalid category, or a transaction with a broken date. If the app accepts invalid data, every summary and filter becomes less reliable.
Validation should happen before updating the shared store. The form component can own temporary input state, validation errors, and submit behavior. Only after the form
is valid should it create a clean Expense object and send it to the store. This keeps the store data trustworthy.
In Svelte, two-way binding makes forms pleasant to build, but it can also hide validation problems. Do not treat a bound input value as valid just because it exists. Trim strings, parse amounts carefully, validate categories, and show clear error messages near the relevant fields.
Problematic approach:
<script>
let title = "";
let amount = "";
let category = "Food";
function submitExpense() {
expensesStore.add({
id: crypto.randomUUID(),
title,
amountCents: Number(amount) * 100,
category,
createdAt: new Date().toISOString()
});
}
</script>
This can submit empty titles, invalid numbers, or amounts that become NaN.
Better validation result:
type ExpenseFormErrors = {
title?: string;
amount?: string;
category?: string;
};
function validateExpenseForm(input: {
title: string;
amount: string;
category: string;
}): {
isValid: boolean;
errors: ExpenseFormErrors;
amountCents: number | null;
} {
const errors: ExpenseFormErrors = {};
const amountCents = parseAmountToCents(input.amount);
if (!input.title.trim()) {
errors.title = "Enter an expense title.";
}
if (amountCents === null) {
errors.amount = "Enter a valid amount greater than zero.";
}
if (!isExpenseCategory(input.category)) {
errors.category = "Choose a valid category.";
}
return {
isValid: Object.keys(errors).length === 0,
errors,
amountCents
};
}
Svelte form submit:
function handleSubmit() {
const result = validateExpenseForm({
title,
amount,
category
});
errors = result.errors;
if (!result.isValid || result.amountCents === null) {
return;
}
expensesStore.add({
id: crypto.randomUUID(),
title: title.trim(),
amountCents: result.amountCents,
category,
note: note.trim() || undefined,
createdAt: new Date().toISOString()
});
title = "";
amount = "";
note = "";
category = "Food";
}
Accessible error message:
<label for="expense-amount">Amount</label>
<input
id="expense-amount"
bind:value={amount}
inputmode="decimal"
aria-describedby={errors.amount ? "amount-error" : undefined}
/>
{#if errors.amount}
<p id="amount-error" class="form-error">
{errors.amount}
</p>
{/if}
Pay attention to: Validate form data before it reaches the store. Clean input state, parsed numbers, category validation, and accessible errors make the app feel much more professional.
6. Designing the tracker as a CRUD list instead of a financial tool
A beginner expense tracker can easily become just a list with add and delete buttons. That is not wrong, but it misses the main value of the project. A good expense tracker should help users understand their spending. That means the interface should make totals, categories, recent transactions, and patterns easy to read.
Think about the user experience. The most important numbers should be visible immediately. Categories should be scannable. Transactions should show amount, title, category, and date. Delete actions should be clear but not too easy to trigger accidentally. Empty states should explain what to do next. On mobile, the form and list should not feel cramped.
This does not mean adding complex charts immediately. A simple but thoughtful layout with summary cards, category chips, readable transaction rows, and responsive spacing is more valuable than a cluttered dashboard full of unfinished widgets.
Problematic layout:
<main>
<form>...</form>
{#each $expensesStore as expense}
<p>{expense.title} {expense.amountCents}</p>
{/each}
</main>
This technically shows the data, but it does not help the user understand their spending quickly.
Better page structure:
<main class="expense-page">
<section class="expense-page__hero">
<h1>Expense Tracker</h1>
<p>Track daily spending and understand where your money goes.</p>
</section>
<SummaryGrid
total={formattedTotal}
transactionCount={transactionCount}
largestExpense={largestExpense}
/>
<section class="expense-page__content">
<ExpenseForm />
<div>
<ExpenseFilters
bind:selectedCategory
bind:searchQuery
/>
<ExpenseList expenses={visibleExpenses} />
</div>
</section>
</main>
Responsive layout idea:
.expense-page__content {
display: grid;
gap: 24px;
}
@media (min-width: 900px) {
.expense-page__content {
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
align-items: start;
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
Pay attention to: Build a small financial product, not only a CRUD demo. Summary, filters, readable rows, empty states, and responsive layout make the project more useful and portfolio-ready.
After completing this project, you will have an impressive beginner-level Svelte portfolio application that demonstrates reactive programming, component composition, writable stores, local persistence, filtering, and derived calculations. More importantly, you will understand how Svelte simplifies state synchronization while still enabling the development of practical, production-style interfaces. These concepts form a strong foundation for building larger Svelte applications involving dashboards, admin panels, financial tools, and productivity software.
Reference Implementations Worth Studying
Simple Svelte expense tracker reference:
codeconcept - Expense Tracker
This is the simplest direct reference for the project because it is a Svelte 3 app focused on tracking expenses. It is useful as a baseline for understanding how a small Svelte application can organize expense data, render a basic interface, and stay close to the fundamentals without adding too much architecture too early.
Pay particular attention to:
- How a small Svelte app can keep the expense-tracking workflow understandable.
- How the project structure separates source files and public assets in a basic Svelte setup.
- How expense entries can be represented before adding more advanced summaries or filters.
- What you would improve with stronger validation, category filters, derived totals, and localStorage persistence.
- How a beginner project can remain focused instead of becoming overloaded with unfinished features.
Use this repository as the basic implementation reference. It is especially helpful if you want to start with a small Svelte expense tracker and then gradually improve it with writable stores, filtering, and financial summaries.
PWA, Firebase, and mobile-friendly reference:
cerivitos - ExpenseTracker
This implementation is useful as the more advanced product direction. It is a simple expense tracker using Svelte, but it also includes installable PWA behavior with a service worker, responsive layout for mobile and desktop, Firestore for storing expense entries, Firebase Storage for image attachments, Google sign-in authentication, current location as a description tag, and dark mode.
When studying the code, focus on:
- How an expense tracker can grow from local-only state into a Firebase-backed app.
- How authentication changes the project from a browser demo into a user-specific financial tool.
- How Firestore can store entries while Firebase Storage can support image attachments.
- How PWA setup and responsive screenshots make the app feel closer to a real mobile product.
- Which features should stay out of the first beginner version until the local data model is stable.
Use this repository as the advanced comparison point. It shows how a small expense tracker can evolve into a more realistic app with cloud storage, authentication, PWA behavior, dark mode, and mobile-first usage.
LocalStorage-backed Svelte store reference:
onsetsoftware - Svelte Local Storage Store
This repository is not an expense tracker, but it is highly relevant for the persistence layer. It provides a thin wrapper around a Svelte writable store, backs the store up to localStorage, and includes built-in event handling to sync values across multiple tabs. That makes it a strong reference for the storage architecture behind your Expense Tracker Project.
While reviewing this project, examine:
- How a localStorage-backed writable store can keep component code cleaner.
- How the store API remains close to Svelte's normal writable store API.
- How persistent state can be shared without manually calling localStorage inside every component.
- How cross-tab synchronization can matter when the same app is open in more than one browser tab.
- How a similar idea can be adapted for transactions, filters, theme preference, and budget settings.
Use this implementation as the persistence-pattern reference. It is especially useful if you want your Svelte expense tracker to demonstrate clean writable-store design instead of scattering localStorage logic throughout the app.