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
addandremove, and onereducerfunction returns the next state. It is easier to follow and test than many setters. - Context:
createContextand 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:
parseCentsreturnsnullfor bad input, which the form turns into a message.
Create the project
- Use a blank Expo TypeScript project and install AsyncStorage with
npx expo install @react-native-async-storage/async-storage. - 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 withdispatch. - 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
- Build the app and reproduce the numbers above.
- Add a monthly budget input and colour the total red above it.
- Add a
datefield to each expense and show this month's total. - 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.