Back to Blogs
7 min read

How I Built an Expense Tracker App for Myself

React NativeMobile DevelopmentSide ProjectNative Modules

I've always been particular about tracking where my money goes. But every expense tracker I tried was either bloated with features I didn't need, required a sign-up, or wanted to sync my bank accounts. And the biggest problem — after making a UPI payment through Google Pay, I'd always forget to log the expense.

So I built my own — with OCR receipt scanning and notification-based expense marking baked in.

The Vision

A personal finance app that's:

  • Offline-first — no accounts, no cloud, no subscriptions
  • Fast — log an expense in under 5 seconds
  • Smart — scan a GPay receipt screenshot and auto-fill the expense
  • Integrated — pay via UPI and mark as paid/failed from the notification bar

Home Screen

Tech Stack

LayerTechnology
FrameworkReact Native CLI 0.83.1 (bare, no Expo)
LanguageTypeScript
NavigationReact Navigation (native-stack + bottom-tabs)
State ManagementReact Context + useReducer
PersistenceAsyncStorage
UI LibraryReact Native Paper (Material Design 3)
Chartsreact-native-gifted-charts
OCR@react-native-ml-kit/text-recognition
Lists@shopify/flash-list
Animationsreact-native-reanimated
PDF Exportreact-native-html-to-pdf
Native ModulesCustom Android UPI + Share bridge

I chose bare React Native CLI over Expo deliberately because I needed full native module access for the UPI payment bridge and notification actions — things that require writing custom Java/Kotlin code.

Application Flows

1. Manual Expense Entry

The simplest flow: tap the FAB → enter amount → pick category → add note → choose payment method → Mark as Paid. The expense is saved to AsyncStorage instantly.

Add Expense

Categories can be selected quickly from the horizontal list, or browse the full list via the "See All" modal with curated color-coded icons.

All Categories

2. UPI Payment Flow with Notification Actions

This is the flow I'm most proud of:

  1. User fills expense details and taps Pay via UPI
  2. App creates a PendingExpense and persists it to AsyncStorage
  3. Native Android module constructs a upi://pay intent and launches the UPI app (GPay, PhonePe, Paytm, etc.)
  4. Simultaneously, a persistent notification appears with Paid and Failed action buttons

Notification Panel Actions

  1. After completing payment in the UPI app, user taps Paid on the notification → expense is marked as completed
  2. If the app was killed during payment, the resumePendingExpense() service checks for stale pending expenses on next launch

Pending Transaction Card

The native UPI module bridges JavaScript ↔ Android:

export const launchUPIPayment = async (
    upiUri: string,
    expenseId: string,
    amount: number,
    description: string
): Promise<UPILaunchResult> => {
    return UPIPaymentModule.launchUPI(upiUri, expenseId, amount, description);
};

The notification actions (Paid/Failed) are handled natively and emit events back to JS via NativeEventEmitter:

export const subscribeToPaymentStatus = (
    callback: (event: PaymentStatusEvent) => void
): (() => void) => {
    const emitter = getEventEmitter();
    const subscription = emitter.addListener('onPaymentStatusUpdate', callback);
    return () => subscription.remove();
};

3. OCR Receipt Scanning (Share Intent)

Take a screenshot of your GPay/UPI transaction receipt → share it to Expense Tracker → the app runs ML Kit OCR and auto-fills the expense form.

The ReceiptService uses a multi-pattern parser optimized for GPay receipts:

  • Amount extraction: Handles ₹20, ₹2,051, Rs. 500, standalone numbers, and "Amount:" labels — with 5 fallback patterns
  • Receiver extraction: Parses "To RAJU VADEWALE" and "Paid to" patterns
  • Note extraction: Finds the user-added message chip between amount and date (e.g., "poha")
  • Date extraction: Parses "27 Jan 2026, 10:13 am" format with AM/PM conversion
const handleSharedImage = async (uri: string) => {
    const details = await ReceiptService.parseReceipt(uri);
    navigationRef.current.navigate('AddExpense', {
        prefilledAmount: details.amount,
        prefilledDescription: details.description,
        receiptImage: uri,
    });
};

4. Analytics Dashboard

The analytics screen provides a comprehensive spending overview with multiple visualizations:

Analytics

  • Time-range filters: Today / Week / Month / Year
  • Summary card: Total spent with transaction count
  • Comparison card: Current vs. previous period with percentage change
  • Donut chart: Category-wise spending breakdown with interactive segments
  • Expandable categories: Tap a category to see individual transactions

Analytics Breakdown

5. Category Management

Create, edit, and delete custom categories with MaterialCommunityIcons and a curated 8-color palette.

Manage Categories

Add Category

Default categories: Food & Dining, Transport, Shopping, Bills & Utilities, Entertainment — each with a unique color and icon.

6. Data Export (CSV & PDF)

Export your expense data for external record-keeping or tax filing. The export service generates:

  • CSV: Standard comma-separated format with all expense fields
  • PDF: Styled HTML report converted via react-native-html-to-pdf, shared through the native share sheet

Export Data

7. Settings & Preferences

Settings

  • Manage Categories (navigate to category management)
  • Currency selection (INR default)
  • Dark/Light mode toggle
  • Export data (CSV/PDF)
  • Backup & Sync (future feature)

8. Transaction History

Transaction History

Full chronological list powered by Shopify's Flash List for buttery-smooth scrolling even with thousands of transactions. Scroll through your complete spending history with category icons, amounts, and payment status.

Architecture Deep Dive

State Management: React Context + useReducer

I chose Context + useReducer over Redux or Zustand because I wanted centralized state without extra dependencies. The 587-line AppContext manages everything:

interface AppContextState {
    categories: Category[];
    expenses: Expense[];
    pendingExpense: PendingExpense | null;
    isLoading: boolean;
    isInitialized: boolean;
    themeMode: ThemeMode;
    quickStats: {
        today: ExpenseSummary;
        thisWeek: ExpenseSummary;
        thisMonth: ExpenseSummary;
    } | null;
}

The context exposes typed action methods: addExpense, updateExpense, deleteExpense, addCategory, startPendingExpense, completePendingExpense, cancelPendingExpense, etc.

Performance: React.lazy() + Flash List

Non-critical screens are lazy-loaded to reduce initial bundle load time:

const AnalyticsScreen = React.lazy(() => import('@/screens/AnalyticsScreen'));
const SettingsScreen = React.lazy(() => import('@/screens/SettingsScreen'));
const ExpenseListScreen = React.lazy(() => import('@/screens/ExpenseListScreen'));

Transaction lists use Shopify's FlashList instead of FlatList for consistent 60fps scrolling performance.

AsyncStorage Service Layer

A typed generic service layer wraps AsyncStorage to avoid raw JSON parsing everywhere:

export async function getStorageItem<T>(key: string): Promise<T | null> {
    const json = await AsyncStorage.getItem(key);
    if (!json) return null;
    return JSON.parse(json);
}

Challenges & Complexities

1. Native Android UPI Module

Writing a custom Java native module to bridge upi://pay intents with persistent notifications that emit events back to JavaScript. The module handles:

  • UPI intent construction and launch
  • Persistent notification with Paid/Failed action buttons
  • NativeEventEmitter for status callbacks
  • Stale pending expense cleanup on app restart

2. OCR Receipt Parsing

Building a reliable parser for GPay transaction screenshots was harder than expected. The OCR output is unstructured text, and different receipt formats (GPay vs PhonePe vs bank apps) have different layouts. I ended up with 5 fallback patterns for amount extraction alone, plus heuristics to distinguish notes from UI labels.

3. Pending Expense State Machine

Managing the lifecycle of a pending UPI expense across app backgrounding, killing, and restart:

  • startPendingExpense → creates and persists
  • completePendingExpense → moves to expense list, clears pending
  • failPendingExpense → saves as failed, clears pending
  • cancelPendingExpense → discards without saving
  • resumePendingExpense → checks for stale (>1 hour) pending expenses on app restart

4. Donut Chart Tuning

Getting react-native-gifted-charts to produce polished donut charts took iteration — inner/outer radius ratios, gradient colors, center label components, empty state handling, and keeping smooth animations with dynamic data updates.

5. Code-Splitting in React Native

Using React.lazy() with Suspense in React Native isn't as straightforward as on the web. Each lazy screen needs a Suspense wrapper with a loading fallback, and Metro bundler handles the code-splitting differently than webpack.

Key Takeaways

  1. Bare React Native CLI gives you superpowers — when you need native modules, don't fight Expo's managed workflow. Go bare.
  2. OCR + heuristic parsing is fragile but useful — multi-pattern fallbacks with graceful degradation make it work in practice.
  3. Notification actions are underutilized — they're perfect for async workflows like "did this payment succeed?"
  4. Offline-first simplifies everything — no loading states, no network errors, no auth flows. Just data in, data out.
  5. React Context + useReducer scales further than you'd think — for a single-user mobile app, it's more than enough.

Check out the source code on GitHub.