Project 5: Expense Tracker with useReducer and Context

React Native with Expo: 10 Real Projects · Building Real Apps · 9 min read · Updated

Actions and a reducer

  1. The user adds an expense. The screen dispatches an action.
  2. The reducer receives the old state and the action.
  3. It returns a brand new state with the item added.
  4. Every component that uses the context redraws with the new list and totals.

This project grows the state of an app. A reducer puts all changes to the list in one place, and Context shares that state with any screen without passing props down by hand. It also introduces a rule every serious app follows: handle money as integers.

Concepts in this project

  • useReducer: you describe changes as actions, such as add and remove, and one reducer function returns the next state. It is easier to follow and test than many setters.
  • Context: createContext and a Provider make state available to the whole tree. A custom hook, useExpenses, hides the details and throws a clear error if used outside the provider.
  • Money as cents: floating point cannot store values like 0.1 exactly, so 0.1 + 0.2 is 0.30000000000000004. Store whole cents and format at the edge.
  • useMemo: caches a calculated value, like totals per category, until its inputs change.
  • Validation: parseCents returns null for bad input, which the form turns into a message.

Create the project

  1. Use a blank Expo TypeScript project and install AsyncStorage with npx expo install @react-native-async-storage/async-storage.
  2. Add three files.
my-app/
  App.tsx
  ExpenseContext.tsx   reducer, provider, useExpenses hook
  money.ts             parseCents and asMoney

`money.ts`

// Money is stored as whole cents so 0.1 + 0.2 style errors never appear.
export function parseCents(text: string): number | null {
  const t = text.trim();
  if (!/^\d+(\.\d{1,2})?$/.test(t)) return null;
  const [whole, frac = ""] = t.split(".");
  const cents = parseInt(whole, 10) * 100 + parseInt(frac.padEnd(2, "0") || "0", 10);
  return cents > 0 ? cents : null;
}

export const asMoney = (cents: number) => (cents / 100).toFixed(2);

`ExpenseContext.tsx`

import { createContext, useContext, useEffect, useReducer, ReactNode } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";

export const CATEGORIES = ["Food", "Travel", "Bills", "Fun", "Other"] as const;
export type Category = (typeof CATEGORIES)[number];
export type Expense = { id: string; title: string; cents: number; category: Category };

type State = { items: Expense[]; loaded: boolean };
type Action =
  | { type: "load"; items: Expense[] }
  | { type: "add"; item: Expense }
  | { type: "remove"; id: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "load":
      return { items: action.items, loaded: true };
    case "add":
      return { ...state, items: [action.item, ...state.items] };
    case "remove":
      return { ...state, items: state.items.filter((e) => e.id !== action.id) };
  }
}

const KEY = "expenses.v1";
type Ctx = { state: State; dispatch: React.Dispatch<Action> };
const ExpenseContext = createContext<Ctx | null>(null);

export function ExpenseProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(reducer, { items: [], loaded: false });

  useEffect(() => {
    AsyncStorage.getItem(KEY)
      .then((raw) => dispatch({ type: "load", items: raw ? JSON.parse(raw) : [] }))
      .catch(() => dispatch({ type: "load", items: [] }));
  }, []);

  useEffect(() => {
    if (state.loaded) AsyncStorage.setItem(KEY, JSON.stringify(state.items));
  }, [state]);

  return <ExpenseContext.Provider value={{ state, dispatch }}>{children}</ExpenseContext.Provider>;
}

export function useExpenses() {
  const ctx = useContext(ExpenseContext);
  if (!ctx) throw new Error("useExpenses must be used inside ExpenseProvider");
  return ctx;
}

`App.tsx`

import { useMemo, useState } from "react";
import { SafeAreaView, View, Text, TextInput, Pressable, FlatList, StyleSheet } from "react-native";
import { CATEGORIES, Category, ExpenseProvider, useExpenses } from "./ExpenseContext";
import { asMoney, parseCents } from "./money";

function Screen() {
  const { state, dispatch } = useExpenses();
  const [title, setTitle] = useState("");
  const [amount, setAmount] = useState("");
  const [category, setCategory] = useState<Category>("Food");
  const [error, setError] = useState("");

  const total = useMemo(() => state.items.reduce((s, e) => s + e.cents, 0), [state.items]);
  const byCategory = useMemo(() => {
    const map = new Map<Category, number>();
    state.items.forEach((e) => map.set(e.category, (map.get(e.category) ?? 0) + e.cents));
    return [...map.entries()];
  }, [state.items]);

  const add = () => {
    const cents = parseCents(amount);
    if (!title.trim() || cents === null) {
      setError("Enter a title and an amount such as 12.50");
      return;
    }
    setError("");
    dispatch({ type: "add", item: { id: Date.now().toString(), title: title.trim(), cents, category } });
    setTitle("");
    setAmount("");
  };

  return (
    <SafeAreaView style={styles.screen}>
      <Text style={styles.total}>{asMoney(total)}</Text>
      <Text style={styles.sub}>Total spent</Text>

      <TextInput style={styles.input} value={title} onChangeText={setTitle} placeholder="Title" />
      <TextInput
        style={styles.input}
        value={amount}
        onChangeText={setAmount}
        placeholder="Amount"
        keyboardType="decimal-pad"
      />
      <View style={styles.chips}>
        {CATEGORIES.map((c) => (
          <Pressable key={c} onPress={() => setCategory(c)} style={[styles.chip, c === category && styles.chipOn]}>
            <Text style={c === category ? styles.chipTextOn : undefined}>{c}</Text>
          </Pressable>
        ))}
      </View>
      {!!error && <Text style={styles.error}>{error}</Text>}
      <Pressable style={styles.button} onPress={add}>
        <Text style={styles.buttonText}>Add expense</Text>
      </Pressable>

      {byCategory.map(([c, cents]) => (
        <Text key={c} style={styles.summary}>
          {c}: {asMoney(cents)}
        </Text>
      ))}

      <FlatList
        data={state.items}
        keyExtractor={(e) => e.id}
        renderItem={({ item }) => (
          <View style={styles.row}>
            <View style={{ flex: 1 }}>
              <Text style={styles.rowTitle}>{item.title}</Text>
              <Text style={styles.rowSub}>{item.category}</Text>
            </View>
            <Text style={styles.rowAmount}>{asMoney(item.cents)}</Text>
            <Pressable onPress={() => dispatch({ type: "remove", id: item.id })}>
              <Text style={styles.del}>  ✕</Text>
            </Pressable>
          </View>
        )}
      />
    </SafeAreaView>
  );
}

export default function App() {
  return (
    <ExpenseProvider>
      <Screen />
    </ExpenseProvider>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 20, backgroundColor: "#f5f6fa" },
  total: { fontSize: 44, fontWeight: "700" },
  sub: { color: "#555", marginBottom: 12 },
  input: { backgroundColor: "#fff", borderRadius: 10, padding: 12, marginBottom: 8, borderWidth: 1, borderColor: "#d0d4dc" },
  chips: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginBottom: 8 },
  chip: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 16, backgroundColor: "#e3e6ee" },
  chipOn: { backgroundColor: "#3b5bdb" },
  chipTextOn: { color: "#fff" },
  error: { color: "#c92a2a", marginBottom: 6 },
  button: { backgroundColor: "#3b5bdb", padding: 12, borderRadius: 10, alignItems: "center", marginBottom: 12 },
  buttonText: { color: "#fff", fontWeight: "600" },
  summary: { color: "#333", marginBottom: 2 },
  row: { flexDirection: "row", alignItems: "center", backgroundColor: "#fff", padding: 12, borderRadius: 10, marginTop: 8 },
  rowTitle: { fontSize: 16, fontWeight: "500" },
  rowSub: { color: "#777" },
  rowAmount: { fontWeight: "700" },
  del: { color: "#d6336c", fontSize: 16 },
});

How it works

  • The reducer handles three actions: load, add, remove. Each returns a new state object.
  • The provider loads from AsyncStorage once and saves every time the state changes after loading.
  • The screen reads the state with useExpenses() and sends actions with dispatch.
  • The form checks the amount with parseCents, so an empty box or text such as "abc" shows an error and never reaches the list.

Expected output

The top shows a big total, 0.00 at first. Add Lunch 12.50 in Food, Bus 3.20 in Travel and Coffee 4.00 in Food. The total reads 19.70 and the summary lines read Food: 16.50 and Travel: 3.20. The list shows three items, newest first. Remove Coffee and the total becomes 15.70. Close and reopen the app: the data remains. Entering abc shows "Enter a title and an amount such as 12.50".

Your task

  1. Build the app and reproduce the numbers above.
  2. Add a monthly budget input and colour the total red above it.
  3. Add a date field to each expense and show this month's total.
  4. Run the money tests from the testing lesson against money.ts.

How to check it worked

  • Totals always equal the sum of the list.
  • Amounts never show floating-point noise.
  • You can explain what a reducer and an action are.

Frequently asked questions

useReducer or useState?

Use useState for simple independent values. Use useReducer when several actions change the same data, because the rules then live in one tested function.

Is Context a replacement for a state library?

For modest apps, yes. Context re-renders every consumer when its value changes, so large or fast-changing state may need a store such as Zustand (project 10).

Why are cents safer than decimals?

Whole numbers are exact. Rounding only happens once, when you display the value.