← Back to Blog
TutorialFebruary 28, 202515 min read

Build a React Expense Dashboard with Auto-Categorization (Step-by-Step)

Learn how to build a full-featured React expense dashboard that automatically categorizes transactions, visualizes spending by category with charts, and detects recurring subscriptions using the Easy Enrichment API.

What We're Building

In this tutorial, we will build a React expense dashboard that takes raw bank transaction descriptions and transforms them into a categorized, visual breakdown of your spending. The finished dashboard will include a spending-by-category bar chart, filterable transaction lists, and a subscription detection panel that surfaces recurring charges automatically. All of the heavy lifting for categorization, merchant identification, and subscription detection is handled by the Easy Enrichment API, so you can focus entirely on the frontend experience.

By the end, you will have a working financial dashboard React application that you can extend with budgets, alerts, or multi-account support. The patterns covered here apply equally well to personal finance apps, small-business expense trackers, and fintech prototypes.

Prerequisites

  • React 18+ -- we will use functional components and hooks throughout
  • Tailwind CSS -- for styling the dashboard layout and chart bars
  • Easy Enrichment API key -- sign up from the dashboard to get 20 free API calls
  • Node.js 18+ -- for running the development server and proxy endpoint

Familiarity with React state management and the Fetch API is assumed. If you are new to transaction enrichment, read Building an Expense Tracker with Easy Enrichment first for background context.

Step 1: Setting Up the Project and API Integration

Start by scaffolding a new React project. We will use Vite for speed, but Create React App or Next.js work just as well. Install Tailwind CSS for styling, then create an API utility module that wraps the Easy Enrichment endpoints.

npm create vite@latest expense-dashboard -- --template react
cd expense-dashboard
npm install
npm install -D tailwindcss @tailwindcss/vite

Next, create a dedicated module for API calls. Store your API key in an environment variable so it never ends up in client-side bundles. In production, proxy these requests through your own backend to keep the key secret.

// src/lib/enrichment.js
const API_BASE = 'https://api.easyenrichment.com';

export async function enrichTransaction(description, apiKey) {
  const response = await fetch(`${API_BASE}/enrich`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ description }),
  });

  if (!response.ok) {
    throw new Error(`Enrichment failed: ${response.status}`);
  }

  return response.json();
}

export async function enrichBatch(descriptions, apiKey) {
  const response = await fetch(`${API_BASE}/enrich/batch`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      transactions: descriptions.map((d) => ({ description: d })),
    }),
  });

  if (!response.ok) {
    throw new Error(`Batch enrichment failed: ${response.status}`);
  }

  return response.json();
}

The enrichBatch function is the one you will use most often. It accepts up to 100 transaction descriptions in a single request, which is critical for keeping latency low when loading a full month of bank data.

Step 2: Fetching and Enriching Transactions

With the API client in place, create a custom hook that manages the enrichment lifecycle. This hook accepts an array of raw transactions (each with a description, amount, and date), sends them to the API in batches, and returns the enriched results along with loading and error states.

// src/hooks/useEnrichedTransactions.js
import { useState, useEffect } from 'react';
import { enrichBatch } from '../lib/enrichment';

const BATCH_SIZE = 100;

export function useEnrichedTransactions(rawTransactions, apiKey) {
  const [enriched, setEnriched] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!rawTransactions.length) return;

    let cancelled = false;
    setLoading(true);
    setError(null);

    async function process() {
      try {
        const results = [];

        for (let i = 0; i < rawTransactions.length; i += BATCH_SIZE) {
          const batch = rawTransactions.slice(i, i + BATCH_SIZE);
          const descriptions = batch.map((t) => t.description);
          const response = await enrichBatch(descriptions, apiKey);

          batch.forEach((tx, idx) => {
            results.push({
              ...tx,
              merchant_name: response.data[idx].merchant_name,
              category: response.data[idx].category,
              logo_url: response.data[idx].logo_url,
              is_subscription: response.data[idx].is_subscription,
              confidence: response.data[idx].confidence,
            });
          });
        }

        if (!cancelled) {
          setEnriched(results);
        }
      } catch (err) {
        if (!cancelled) setError(err.message);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

    process();
    return () => { cancelled = true; };
  }, [rawTransactions, apiKey]);

  return { enriched, loading, error };
}

The hook processes transactions in chunks of 100 to stay within the batch endpoint limit. The cancellation flag prevents state updates if the component unmounts mid-request. For a production app, you would also want to cache enriched results in localStorage or a database so repeat visits do not consume additional API calls.

Step 3: Building the Category Breakdown Component

The category breakdown is the centrepiece of any expense dashboard. It groups enriched transactions by the category field returned by the API, sums the amounts, and displays each category with its percentage of total spending.

// src/components/CategoryBreakdown.jsx
import { useMemo } from 'react';

const CATEGORY_COLORS = {
  'Food & Dining': '#f59e0b',
  'Shopping': '#8b5cf6',
  'Transportation': '#3b82f6',
  'Entertainment': '#ec4899',
  'Utilities': '#10b981',
  'Health': '#ef4444',
  'Travel': '#06b6d4',
  'Subscriptions': '#a855f7',
};

function groupByCategory(transactions) {
  const groups = {};

  transactions.forEach((tx) => {
    const cat = tx.category || 'Other';
    if (!groups[cat]) {
      groups[cat] = { total: 0, count: 0 };
    }
    groups[cat].total += Math.abs(tx.amount);
    groups[cat].count += 1;
  });

  const grandTotal = Object.values(groups).reduce((s, g) => s + g.total, 0);

  return Object.entries(groups)
    .map(([category, data]) => ({
      category,
      total: data.total,
      count: data.count,
      percentage: grandTotal > 0 ? (data.total / grandTotal) * 100 : 0,
    }))
    .sort((a, b) => b.total - a.total);
}

export default function CategoryBreakdown({ transactions }) {
  const categories = useMemo(
    () => groupByCategory(transactions),
    [transactions]
  );

  return (
    <div className="rounded-xl border border-zinc-800 bg-[#111113] p-6">
      <h3 className="text-lg font-semibold text-white mb-4">
        Spending by Category
      </h3>
      <div className="space-y-3">
        {categories.map((cat) => (
          <div key={cat.category}>
            <div className="flex justify-between text-sm mb-1">
              <span className="text-zinc-300">{cat.category}</span>
              <span className="text-zinc-400">
                ${cat.total.toFixed(2)} ({cat.percentage.toFixed(1)}%)
              </span>
            </div>
            <div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
              <div
                className="h-full rounded-full"
                style={{
                  width: `${cat.percentage}%`,
                  backgroundColor:
                    CATEGORY_COLORS[cat.category] || '#71717a',
                }}
              />
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

The groupByCategory function is a pure utility that takes an array of enriched transactions and returns a sorted list of category objects. The component uses useMemo to avoid recomputing the grouping on every render. Each category gets a colour-coded progress bar whose width is set as a percentage of total spending.

Step 4: Creating a Spending Chart

A bar chart makes spending patterns immediately visible. Rather than pulling in a heavyweight charting library, we can build a simple horizontal bar chart with plain Tailwind classes. This keeps the bundle small and gives you full control over styling.

// src/components/SpendingChart.jsx
import { useMemo } from 'react';

export default function SpendingChart({ transactions, groupBy = 'category' }) {
  const chartData = useMemo(() => {
    const groups = {};

    transactions.forEach((tx) => {
      let key;
      if (groupBy === 'category') {
        key = tx.category || 'Other';
      } else if (groupBy === 'merchant') {
        key = tx.merchant_name || tx.description;
      } else {
        // Group by month
        const d = new Date(tx.date);
        key = d.toLocaleString('default', { month: 'short', year: 'numeric' });
      }

      groups[key] = (groups[key] || 0) + Math.abs(tx.amount);
    });

    const entries = Object.entries(groups).sort((a, b) => b[1] - a[1]);
    const max = entries.length > 0 ? entries[0][1] : 1;

    return entries.slice(0, 10).map(([label, value]) => ({
      label,
      value,
      width: (value / max) * 100,
    }));
  }, [transactions, groupBy]);

  return (
    <div className="rounded-xl border border-zinc-800 bg-[#111113] p-6">
      <h3 className="text-lg font-semibold text-white mb-4">
        Top Spending {groupBy === 'category' ? 'Categories' : 'Merchants'}
      </h3>
      <div className="space-y-3">
        {chartData.map((item) => (
          <div key={item.label} className="flex items-center gap-3">
            <span className="text-sm text-zinc-400 w-32 truncate">
              {item.label}
            </span>
            <div className="flex-1 h-6 bg-zinc-800 rounded">
              <div
                className="h-full bg-indigo-500 rounded flex items-center justify-end px-2"
                style={{ width: `${Math.max(item.width, 8)}%` }}
              >
                <span className="text-xs text-white font-medium">
                  ${item.value.toFixed(0)}
                </span>
              </div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

The chart accepts a groupBy prop so you can reuse it for category, merchant, or monthly views. The width of each bar is calculated relative to the largest value, ensuring the top item always fills the full width. A minimum width of 8% keeps very small values visible.

Step 5: Adding Filters

A dashboard without filters forces users to scroll through every transaction. Adding date range, category, and merchant filters lets users drill down to exactly the data they care about. Here is a filter bar component that manages all three filter types and passes the filtered list downstream.

// src/components/FilterBar.jsx
import { useState, useMemo } from 'react';

export default function FilterBar({ transactions, onFilter }) {
  const [dateRange, setDateRange] = useState({ start: '', end: '' });
  const [selectedCategory, setSelectedCategory] = useState('all');
  const [merchantSearch, setMerchantSearch] = useState('');

  const categories = useMemo(() => {
    const cats = new Set(transactions.map((t) => t.category).filter(Boolean));
    return ['all', ...Array.from(cats).sort()];
  }, [transactions]);

  const applyFilters = () => {
    let filtered = [...transactions];

    // Date filter
    if (dateRange.start) {
      filtered = filtered.filter((t) => t.date >= dateRange.start);
    }
    if (dateRange.end) {
      filtered = filtered.filter((t) => t.date <= dateRange.end);
    }

    // Category filter
    if (selectedCategory !== 'all') {
      filtered = filtered.filter((t) => t.category === selectedCategory);
    }

    // Merchant search
    if (merchantSearch.trim()) {
      const query = merchantSearch.toLowerCase();
      filtered = filtered.filter(
        (t) =>
          t.merchant_name?.toLowerCase().includes(query) ||
          t.description.toLowerCase().includes(query)
      );
    }

    onFilter(filtered);
  };

  return (
    <div className="flex flex-wrap gap-3 items-end">
      <div>
        <label className="block text-xs text-zinc-500 mb-1">From</label>
        <input
          type="date"
          value={dateRange.start}
          onChange={(e) => {
            setDateRange((r) => ({ ...r, start: e.target.value }));
          }}
          className="rounded-lg bg-zinc-800 border border-zinc-700 text-sm text-white px-3 py-2"
        />
      </div>
      <div>
        <label className="block text-xs text-zinc-500 mb-1">To</label>
        <input
          type="date"
          value={dateRange.end}
          onChange={(e) => {
            setDateRange((r) => ({ ...r, end: e.target.value }));
          }}
          className="rounded-lg bg-zinc-800 border border-zinc-700 text-sm text-white px-3 py-2"
        />
      </div>
      <div>
        <label className="block text-xs text-zinc-500 mb-1">Category</label>
        <select
          value={selectedCategory}
          onChange={(e) => setSelectedCategory(e.target.value)}
          className="rounded-lg bg-zinc-800 border border-zinc-700 text-sm text-white px-3 py-2"
        >
          {categories.map((c) => (
            <option key={c} value={c}>
              {c === 'all' ? 'All Categories' : c}
            </option>
          ))}
        </select>
      </div>
      <div>
        <label className="block text-xs text-zinc-500 mb-1">Merchant</label>
        <input
          type="text"
          placeholder="Search merchants..."
          value={merchantSearch}
          onChange={(e) => setMerchantSearch(e.target.value)}
          className="rounded-lg bg-zinc-800 border border-zinc-700 text-sm text-white px-3 py-2"
        />
      </div>
      <button
        onClick={applyFilters}
        className="btn-primary rounded-lg px-4 py-2 text-sm font-medium"
      >
        Apply Filters
      </button>
    </div>
  );
}

The category dropdown is populated dynamically from the enriched transactions, so it always reflects the categories actually present in the data. The merchant search uses a simple substring match against both the enriched merchant_name and the raw description as a fallback.

Step 6: Subscription Detection Panel

The Easy Enrichment API returns an is_subscription boolean for each transaction. This makes it straightforward to build a dedicated panel that lists all recurring charges, calculates their monthly cost, and highlights how much of your spending goes to subscriptions. For a deeper dive into subscription detection, see the Complete Guide to Subscription Detection.

// src/components/SubscriptionPanel.jsx
import { useMemo } from 'react';

export default function SubscriptionPanel({ transactions }) {
  const subscriptions = useMemo(() => {
    const subs = transactions.filter((t) => t.is_subscription);

    // Deduplicate by merchant name and sum amounts
    const byMerchant = {};
    subs.forEach((tx) => {
      const name = tx.merchant_name || tx.description;
      if (!byMerchant[name]) {
        byMerchant[name] = {
          merchant: name,
          logo_url: tx.logo_url,
          charges: [],
          totalSpent: 0,
        };
      }
      byMerchant[name].charges.push(tx);
      byMerchant[name].totalSpent += Math.abs(tx.amount);
    });

    return Object.values(byMerchant).sort(
      (a, b) => b.totalSpent - a.totalSpent
    );
  }, [transactions]);

  const monthlyEstimate = subscriptions.reduce((sum, s) => {
    // Estimate monthly cost from average charge
    const avg = s.totalSpent / s.charges.length;
    return sum + avg;
  }, 0);

  return (
    <div className="rounded-xl border border-zinc-800 bg-[#111113] p-6">
      <div className="flex justify-between items-center mb-4">
        <h3 className="text-lg font-semibold text-white">
          Subscriptions Detected
        </h3>
        <span className="text-sm text-zinc-400">
          ~${monthlyEstimate.toFixed(2)}/mo estimated
        </span>
      </div>
      <div className="space-y-3">
        {subscriptions.map((sub) => (
          <div
            key={sub.merchant}
            className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900/50"
          >
            <img
              src={sub.logo_url || '/placeholder-logo.png'}
              alt={sub.merchant}
              className="w-8 h-8 rounded-lg object-cover"
            />
            <div className="flex-1">
              <div className="text-sm font-medium text-white">
                {sub.merchant}
              </div>
              <div className="text-xs text-zinc-500">
                {sub.charges.length} charge{sub.charges.length !== 1 && 's'} found
              </div>
            </div>
            <div className="text-sm font-medium text-white">
              ${sub.totalSpent.toFixed(2)}
            </div>
          </div>
        ))}
        {subscriptions.length === 0 && (
          <p className="text-sm text-zinc-500">
            No subscriptions detected in the current data.
          </p>
        )}
      </div>
    </div>
  );
}

The panel deduplicates subscriptions by merchant name and estimates a monthly cost based on the average charge amount. In a production app, you could refine this by analysing charge frequency (weekly, monthly, annual) to produce a more accurate projection.

Putting It All Together

With all the components built, the main dashboard page wires everything together. The layout uses a two-column grid on larger screens: the left column holds the chart and category breakdown, while the right column shows the subscription panel and the filtered transaction list.

// src/App.jsx
import { useState } from 'react';
import { useEnrichedTransactions } from './hooks/useEnrichedTransactions';
import FilterBar from './components/FilterBar';
import CategoryBreakdown from './components/CategoryBreakdown';
import SpendingChart from './components/SpendingChart';
import SubscriptionPanel from './components/SubscriptionPanel';

const API_KEY = import.meta.env.VITE_ENRICHMENT_API_KEY;

// Sample raw transactions -- replace with your data source
const RAW_TRANSACTIONS = [
  { id: 1, description: 'AMZN MKTP US*AB1CD2EF3', amount: -29.99, date: '2025-02-15' },
  { id: 2, description: 'SPOTIFY P1234567890', amount: -9.99, date: '2025-02-14' },
  { id: 3, description: 'UBER *TRIP EATS', amount: -18.50, date: '2025-02-13' },
  { id: 4, description: 'NETFLIX.COM', amount: -15.49, date: '2025-02-01' },
  // ...more transactions
];

export default function App() {
  const { enriched, loading, error } = useEnrichedTransactions(
    RAW_TRANSACTIONS,
    API_KEY
  );
  const [filtered, setFiltered] = useState(null);

  const displayData = filtered || enriched;

  if (loading) {
    return (
      <div className="min-h-screen bg-[#09090b] flex items-center justify-center">
        <p className="text-zinc-400">Enriching transactions...</p>
      </div>
    );
  }

  if (error) {
    return (
      <div className="min-h-screen bg-[#09090b] flex items-center justify-center">
        <p className="text-red-400">Error: {error}</p>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-[#09090b] text-white p-6">
      <h1 className="text-2xl font-bold mb-6">Expense Dashboard</h1>

      <FilterBar transactions={enriched} onFilter={setFiltered} />

      <div className="grid lg:grid-cols-2 gap-6 mt-6">
        <SpendingChart transactions={displayData} groupBy="category" />
        <CategoryBreakdown transactions={displayData} />
        <SubscriptionPanel transactions={displayData} />
      </div>
    </div>
  );
}

Performance Tips

  • Use batch enrichment: Always prefer the /enrich/batch endpoint over individual calls. Sending 100 transactions in one request is dramatically faster than making 100 separate requests and consumes only one API call from your quota.
  • Cache enriched data: Once a transaction description has been enriched, the result will not change. Store the enriched data in localStorage, IndexedDB, or your backend database so returning users see instant results without burning API calls.
  • Debounce filter inputs: If you wire up the filters to run on every keystroke, the merchant search can cause unnecessary re-renders. Add a 300ms debounce to text inputs for a smoother experience.
  • Lazy-load the chart: If the transaction list is long, defer chart rendering until the user scrolls to it or switches to a "Charts" tab. This keeps the initial paint fast.
  • Paginate the transaction list: Rendering hundreds of DOM nodes slows down the browser. Show 20-50 transactions at a time with a "Load more" button or virtual scrolling.

Ready to Build Your Dashboard?

Sign up for a free API key and start enriching transactions in minutes. Check out our interactive examples to see the API in action before writing any code.