TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

Build an Expense Tracker with React and SQLite

Practical insights, clear explanations, and useful ideas for understanding the web.

October 25, 202610 min read6 topics
Build an Expense Tracker with React and SQLite
Explore the details
#React#Full-Stack Development#SQLite#CRUD#Portfolio Projects#Web Development
October 25, 202610 min read

A personal expense tracker is a useful full-stack project: it combines a React interface with an API, input validation, and persistent storage. Building one also raises questions a front-end-only demo can skip, such as how to represent money accurately and how to protect records that may reveal sensitive habits.

This tutorial uses React, a small Express API, and SQLite. It covers adding, viewing, editing, and deleting expenses, plus accessible forms, basic tests, and privacy considerations. The example is a learning project, not a secure financial product.

Key takeaways

  • Store amounts as integer cents rather than floating-point currency values.
  • Validate data in the API as well as in the browser; client-side checks can be bypassed.
  • Use parameterized SQL queries and scope every record to its owner if you add accounts.
  • Make form labels, validation messages, and actions usable with a keyboard and assistive technology.
  • Test money conversion and API behavior, and avoid collecting financial data you do not need.

Plan the architecture and data model

Keep the project in three parts:

  1. React client: displays expense records and provides a form for creating or editing them.
  2. Express API: validates requests and exposes CRUD endpoints.
  3. SQLite database: stores records between app restarts.

During local development, a React development server can proxy /api requests to Express. In production, deploy the client and API behind a deliberate same-origin setup or configure cross-origin access narrowly. Do not treat a development proxy as a production security control.

A practical record has an ID, a description, an amount in cents, a date, and a category. Keep the amount as an integer: binary floating-point cannot represent every decimal fraction exactly, so repeated arithmetic on values like 0.1 can produce rounding surprises.

The example uses a fixed set of categories to keep validation straightforward. You can replace it with a database-backed category list later.

Create the database and CRUD API

Install Express and the SQLite driver in the server project, then create a table when the server starts. This small example uses better-sqlite3; use your preferred migration approach as the project grows.

import express from "express";
import Database from "better-sqlite3";

const app = express();
const db = new Database("expenses.sqlite");
const categories = new Set(["Food", "Transport", "Housing", "Health", "Other"]);

app.use(express.json({ limit: "10kb" }));

db.prepare(`
  CREATE TABLE IF NOT EXISTS expenses (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    description TEXT NOT NULL,
    amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
    date TEXT NOT NULL,
    category TEXT NOT NULL
  )
`).run();

function isDateOnly(value) {
  if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
    return false;
  }
  const parsed = new Date(`${value}T00:00:00.000Z`);
  return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
}

function validateExpense(body) {
  const description = typeof body?.description === "string"
    ? body.description.trim()
    : "";
  const { amountCents, date, category } = body ?? {};

  if (description.length < 1 || description.length > 120) return null;
  if (!Number.isSafeInteger(amountCents) || amountCents <= 0) return null;
  if (!isDateOnly(date) || !categories.has(category)) return null;

  return { description, amountCents, date, category };
}

function parseId(value) {
  const id = Number(value);
  return Number.isSafeInteger(id) && id > 0 ? id : null;
}

app.get("/api/expenses", (_req, res) => {
  const rows = db.prepare(`
    SELECT id, description, amount_cents AS amountCents, date, category
    FROM expenses
    ORDER BY date DESC, id DESC
  `).all();
  res.json(rows);
});

app.post("/api/expenses", (req, res) => {
  const expense = validateExpense(req.body);
  if (!expense) return res.status(400).json({ error: "Invalid expense data." });

  const result = db.prepare(`
    INSERT INTO expenses (description, amount_cents, date, category)
    VALUES (@description, @amountCents, @date, @category)
  `).run(expense);

  res.status(201).json({ id: Number(result.lastInsertRowid), ...expense });
});

app.put("/api/expenses/:id", (req, res) => {
  const id = parseId(req.params.id);
  const expense = validateExpense(req.body);
  if (!id || !expense) return res.status(400).json({ error: "Invalid expense data." });

  const result = db.prepare(`
    UPDATE expenses
    SET description = @description,
        amount_cents = @amountCents,
        date = @date,
        category = @category
    WHERE id = @id
  `).run({ ...expense, id });

  if (result.changes === 0) return res.status(404).json({ error: "Expense not found." });
  res.json({ id, ...expense });
});

app.delete("/api/expenses/:id", (req, res) => {
  const id = parseId(req.params.id);
  if (!id) return res.status(400).json({ error: "Invalid expense ID." });

  const result = db.prepare("DELETE FROM expenses WHERE id = ?").run(id);
  if (result.changes === 0) return res.status(404).json({ error: "Expense not found." });
  res.status(204).end();
});

app.listen(3000, () => console.log("API listening on port 3000"));

The API exposes GET /api/expenses, POST /api/expenses, PUT /api/expenses/:id, and DELETE /api/expenses/:id. The SQL statements use parameters rather than inserting user-provided strings into SQL. The server also checks each field, even though the client will validate inputs too. Consider returning consistent JSON errors for malformed JSON and unexpected server failures in a fuller application.

This sample intentionally has no user accounts: anyone who can reach the API can view or change all records. Do not deploy it with real personal expenses as-is. Adding a login screen alone is not enough; the API must authenticate requests and authorize access to each record.

Build an accessible React form and list

The client should send amounts as integer cents, while letting people enter familiar decimal amounts. A strict conversion function avoids silently accepting extra decimal places or malformed input. Put it in a small module such as money.js so it can be tested independently.

export function toCents(value) {
  if (typeof value !== "string") return null;
  const match = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/.exec(value.trim());
  if (!match) return null;

  const whole = Number(match[1]);
  const fraction = Number((match[2] ?? "").padEnd(2, "0"));
  const cents = whole * 100 + fraction;
  return Number.isSafeInteger(cents) && cents > 0 ? cents : null;
}

Here is a compact React component showing the main client-side workflow. It loads records, submits new or edited records, and deletes records. The server remains authoritative: a user can modify browser code or send requests without using this form.

import { useEffect, useState } from "react";
import { toCents } from "./money.js";

async function request(url, options) {
  const response = await fetch(url, options);
  if (!response.ok) {
    const body = await response.json().catch(() => ({}));
    throw new Error(body.error ?? "The request failed.");
  }
  return response.status === 204 ? null : response.json();
}

export default function Expenses() {
  const [expenses, setExpenses] = useState([]);
  const [editingId, setEditingId] = useState(null);
  const [error, setError] = useState("");
  const [form, setForm] = useState({
    description: "",
    amount: "",
    date: new Date().toISOString().slice(0, 10),
    category: "Food",
  });

  async function loadExpenses() {
    setExpenses(await request("/api/expenses"));
  }

  useEffect(() => {
    loadExpenses().catch((err) => setError(err.message));
  }, []);

  function resetForm() {
    setEditingId(null);
    setForm({
      description: "",
      amount: "",
      date: new Date().toISOString().slice(0, 10),
      category: "Food",
    });
  }

  async function handleSubmit(event) {
    event.preventDefault();
    setError("");

    const amountCents = toCents(form.amount);
    if (!form.description.trim() || !amountCents) {
      setError("Enter a description and a valid positive amount with up to two decimal places.");
      return;
    }

    const { amount, ...fields } = form;
    const payload = { ...fields, description: form.description.trim(), amountCents };
    const url = editingId ? `/api/expenses/${editingId}` : "/api/expenses";
    const method = editingId ? "PUT" : "POST";

    try {
      await request(url, {
        method,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });
      await loadExpenses();
      resetForm();
    } catch (err) {
      setError(err.message);
    }
  }

  function startEditing(expense) {
    setEditingId(expense.id);
    setForm({
      description: expense.description,
      amount: (expense.amountCents / 100).toFixed(2),
      date: expense.date,
      category: expense.category,
    });
  }

  async function removeExpense(id) {
    setError("");
    try {
      await request(`/api/expenses/${id}`, { method: "DELETE" });
      await loadExpenses();
      if (editingId === id) resetForm();
    } catch (err) {
      setError(err.message);
    }
  }

  return (
    <main>
      <h1>Expenses</h1>
      {error && <p role="alert">{error}</p>}

      <form onSubmit={handleSubmit}>
        <label htmlFor="description">Description</label>
        <input
          id="description"
          value={form.description}
          maxLength={120}
          required
          onChange={(event) => setForm({ ...form, description: event.target.value })}
        />

        <label htmlFor="amount">Amount</label>
        <input
          id="amount"
          type="text"
          inputMode="decimal"
          value={form.amount}
          required
          aria-describedby="amount-help"
          onChange={(event) => setForm({ ...form, amount: event.target.value })}
        />
        <small id="amount-help">Enter a positive amount with up to two decimal places.</small>

        <label htmlFor="date">Date</label>
        <input
          id="date"
          type="date"
          value={form.date}
          required
          onChange={(event) => setForm({ ...form, date: event.target.value })}
        />

        <label htmlFor="category">Category</label>
        <select
          id="category"
          value={form.category}
          onChange={(event) => setForm({ ...form, category: event.target.value })}
        >
          {["Food", "Transport", "Housing", "Health", "Other"].map((category) => (
            <option key={category} value={category}>{category}</option>
          ))}
        </select>

        <button type="submit">{editingId ? "Save changes" : "Add expense"}</button>
        {editingId && <button type="button" onClick={resetForm}>Cancel edit</button>}
      </form>

      <h2>Recent expenses</h2>
      {expenses.length === 0 ? (
        <p>No expenses yet.</p>
      ) : (
        <ul>
          {expenses.map((expense) => (
            <li key={expense.id}>
              {expense.date} — {expense.description} — {(expense.amountCents / 100).toFixed(2)} — {expense.category}
              <button type="button" onClick={() => startEditing(expense)}>Edit</button>
              <button type="button" onClick={() => removeExpense(expense.id)}>Delete</button>
            </li>
          ))}
        </ul>
      )}
    </main>
  );
}

Every control has a visible label, and the error message uses role="alert" so assistive technology can announce it. For a polished interface, format amounts with Intl.NumberFormat and specify the currency explicitly; the example omits a currency field and therefore should not be used for multi-currency tracking. Also consider focus management after saving or editing, and ensure buttons have clear names when the design makes their purpose less obvious.

The form uses a text field with inputMode="decimal" rather than relying on a browser number field to enforce currency precision. Browser input behavior varies, so the conversion function and API validation still matter.

Test the money conversion and CRUD behavior

Start with unit tests for rules that are easy to get wrong. For example, if you use Vitest, save this beside money.js as money.test.js:

import { describe, expect, it } from "vitest";
import { toCents } from "./money.js";

describe("toCents", () => {
  it("converts decimal amounts to integer cents", () => {
    expect(toCents("12")).toBe(1200);
    expect(toCents("12.3")).toBe(1230);
    expect(toCents("0.05")).toBe(5);
  });

  it("rejects invalid, non-positive, or over-precise amounts", () => {
    expect(toCents("0")).toBeNull();
    expect(toCents("-2.00")).toBeNull();
    expect(toCents("1.234")).toBeNull();
    expect(toCents("abc")).toBeNull();
  });
});

Then test the API with an isolated test database. Cover creating a valid record, rejecting invalid amounts and dates, reading the created record, updating it, returning 404 for a missing ID, and deleting it. Avoid running tests against a database that contains personal records. For confidence in the UI, add a component test for submitting the form and showing server validation errors.

Authentication, privacy, and deployment limits

Expense records can reveal routines, health-related spending, or other personal details. Treat them as sensitive even if this is a portfolio project.

If you add accounts:

  • Authenticate users on the server, and associate each expense with a user ID.
  • Include the authenticated user in every read, update, and delete query. For example, updates should match both the record ID and its owner ID, not just the record ID.
  • Do not trust a user ID supplied in the request body as proof of ownership.
  • Use a reputable authentication approach, protect sessions or tokens, and apply appropriate rate limits and secure transport in deployment.
  • Keep secrets out of source control, restrict database and backup access, and define how users can export or delete their data.

SQLite provides persistence, but this example does not configure encryption, backups, access controls, or account isolation. Those controls depend on the deployment environment and application design. Do not store bank login credentials, card numbers, or other payment credentials in this tracker. A URL parser or browser-side validation is not a substitute for server-side authentication, authorization, and data protection.

Common mistakes to avoid

  • Using floating-point values as the stored amount: use integer minor units, such as cents, and define how you handle currencies with different minor units if you expand the project.
  • Validating only in React: requests can be sent directly to the API, so repeat validation on the server and enforce important constraints in the database where practical.
  • Building SQL with string concatenation: use prepared statements and keep dynamic values out of SQL syntax.
  • Treating an ID as authorization: IDs are identifiers, not access controls. Scope queries by the authenticated owner.
  • Assuming a successful local demo is deployment-ready: production needs a considered approach to authentication, HTTPS, error handling, logging, backups, and privacy.
  • Ignoring date and currency assumptions: decide whether dates represent local calendar days and which currency the tracker uses; store and display those choices consistently.

Conclusion

A React expense tracker is a strong beginner full-stack project when it demonstrates more than a form and a list. Use a small API and persistent database, implement all four CRUD operations, validate on both client and server, and test money handling. If the app stores real records or supports multiple users, add ownership checks and appropriate privacy and security controls before deployment. The tutorial’s single-user example is a foundation for learning, not a secure financial service.