Building a Crypto Portfolio Tracker with JavaScript

Crypto 22 min read

Build a production-ready cryptocurrency portfolio tracker from scratch using JavaScript and React. This comprehensive tutorial covers real-time price updates via WebSocket, portfolio value calculations, profit/loss tracking, persistent storage with localStorage, interactive charts with Chart.js, and responsive UI design. Perfect for developers looking to create crypto applications.

1. Project Overview & Features

What We're Building

A full-featured cryptocurrency portfolio tracker with these capabilities:

Core Features

  • Add/remove cryptocurrency holdings with purchase price and quantity
  • Real-time price updates from CoinGecko API
  • Automatic portfolio value calculation (current value, total cost, profit/loss)
  • Interactive charts showing portfolio allocation and performance
  • LocalStorage persistence - data survives page refreshes
  • Responsive design - works on desktop, tablet, and mobile
  • Search for cryptocurrencies from 10,000+ supported assets
  • 24-hour price change tracking with color indicators

Technology Stack

  • React 18: Component-based UI framework
  • Chart.js: Beautiful, responsive charts
  • CoinGecko API: Free cryptocurrency price data
  • LocalStorage API: Client-side data persistence
  • Tailwind CSS: Utility-first styling (or vanilla CSS)

Live Demo Preview: By the end of this tutorial, you'll have a working portfolio tracker that updates prices every 60 seconds, displays profit/loss percentages, and persists your holdings across browser sessions.

2. Project Setup & Dependencies

Create React App

# Create new React project
npx create-react-app crypto-portfolio-tracker
cd crypto-portfolio-tracker

# Install dependencies
npm install chart.js react-chartjs-2

# Optional: Install Tailwind CSS
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

# Start development server
npm start

Project Structure

src/
├── components/
│   ├── PortfolioSummary.jsx      # Total value, P&L display
│   ├── AddCoinForm.jsx            # Form to add holdings
│   ├── HoldingsList.jsx           # List of current holdings
│   ├── PortfolioChart.jsx         # Pie chart allocation
│   ├── PerformanceChart.jsx       # Historical performance
│   └── CoinSearch.jsx             # Search cryptocurrency
├── services/
│   ├── coinGeckoAPI.js            # API service layer
│   └── portfolioStorage.js        # LocalStorage management
├── utils/
│   ├── calculations.js            # Portfolio math
│   └── formatters.js              # Number/currency formatting
├── hooks/
│   ├── usePortfolio.js            # Portfolio state management
│   └── usePriceUpdates.js         # Real-time price updates
├── App.jsx                         # Main app component
└── index.js                        # Entry point

3. Building the API Service Layer

CoinGecko API Service

// src/services/coinGeckoAPI.js
const COINGECKO_API = 'https://api.coingecko.com/api/v3';

class CoinGeckoService {
    constructor() {
        this.cache = new Map();
        this.cacheExpiry = 60000; // 1 minute
    }

    /**
     * Search for cryptocurrencies by name or symbol
     */
    async searchCoins(query) {
        try {
            const response = await fetch(`${COINGECKO_API}/search?query=${query}`);
            const data = await response.json();

            return data.coins.slice(0, 10).map(coin => ({
                id: coin.id,
                symbol: coin.symbol.toUpperCase(),
                name: coin.name,
                thumb: coin.thumb
            }));
        } catch (error) {
            console.error('Search error:', error);
            return [];
        }
    }

    /**
     * Get current prices for multiple coins
     */
    async getPrices(coinIds, currency = 'usd') {
        const cacheKey = `prices_${coinIds.join(',')}_${currency}`;

        // Check cache
        if (this.cache.has(cacheKey)) {
            const cached = this.cache.get(cacheKey);
            if (Date.now() - cached.timestamp < this.cacheExpiry) {
                return cached.data;
            }
        }

        try {
            const ids = coinIds.join(',');
            const url = `${COINGECKO_API}/simple/price?ids=${ids}&vs_currencies=${currency}&include_24hr_change=true&include_market_cap=true`;

            const response = await fetch(url);
            const data = await response.json();

            // Cache result
            this.cache.set(cacheKey, {
                data,
                timestamp: Date.now()
            });

            return data;
        } catch (error) {
            console.error('Price fetch error:', error);
            return {};
        }
    }

    /**
     * Get detailed information about a coin
     */
    async getCoinDetails(coinId) {
        try {
            const response = await fetch(`${COINGECKO_API}/coins/${coinId}`);
            const data = await response.json();

            return {
                id: data.id,
                symbol: data.symbol.toUpperCase(),
                name: data.name,
                image: data.image.large,
                current_price: data.market_data.current_price.usd,
                market_cap: data.market_data.market_cap.usd,
                price_change_24h: data.market_data.price_change_percentage_24h,
                ath: data.market_data.ath.usd,
                atl: data.market_data.atl.usd
            };
        } catch (error) {
            console.error('Coin details error:', error);
            return null;
        }
    }

    /**
     * Get historical prices for a coin
     */
    async getHistoricalPrices(coinId, days = 30) {
        try {
            const url = `${COINGECKO_API}/coins/${coinId}/market_chart?vs_currency=usd&days=${days}&interval=daily`;
            const response = await fetch(url);
            const data = await response.json();

            return data.prices.map(([timestamp, price]) => ({
                date: new Date(timestamp),
                price
            }));
        } catch (error) {
            console.error('Historical data error:', error);
            return [];
        }
    }

    /**
     * Clear cache
     */
    clearCache() {
        this.cache.clear();
    }
}

export default new CoinGeckoService();

Rate Limiting: CoinGecko free tier allows 10-50 calls/minute. The cache implementation prevents unnecessary API calls. Always implement caching for production apps.

4. Portfolio Calculations & Logic

Portfolio Math Functions

// src/utils/calculations.js

/**
 * Calculate total portfolio value at current prices
 */
export function calculateTotalValue(holdings, prices) {
    return holdings.reduce((total, holding) => {
        const currentPrice = prices[holding.coinId]?.usd || 0;
        return total + (holding.quantity * currentPrice);
    }, 0);
}

/**
 * Calculate total cost basis (what you paid)
 */
export function calculateTotalCost(holdings) {
    return holdings.reduce((total, holding) => {
        return total + (holding.quantity * holding.purchasePrice);
    }, 0);
}

/**
 * Calculate profit/loss for entire portfolio
 */
export function calculateProfitLoss(holdings, prices) {
    const currentValue = calculateTotalValue(holdings, prices);
    const totalCost = calculateTotalCost(holdings);

    return {
        amount: currentValue - totalCost,
        percentage: totalCost > 0 ? ((currentValue - totalCost) / totalCost) * 100 : 0,
        currentValue,
        totalCost
    };
}

/**
 * Calculate profit/loss for individual holding
 */
export function calculateHoldingProfitLoss(holding, currentPrice) {
    const currentValue = holding.quantity * currentPrice;
    const costBasis = holding.quantity * holding.purchasePrice;
    const profitLoss = currentValue - costBasis;
    const profitLossPercent = (profitLoss / costBasis) * 100;

    return {
        currentValue,
        costBasis,
        profitLoss,
        profitLossPercent
    };
}

/**
 * Calculate portfolio allocation percentages
 */
export function calculateAllocation(holdings, prices) {
    const totalValue = calculateTotalValue(holdings, prices);

    return holdings.map(holding => {
        const currentPrice = prices[holding.coinId]?.usd || 0;
        const holdingValue = holding.quantity * currentPrice;
        const percentage = totalValue > 0 ? (holdingValue / totalValue) * 100 : 0;

        return {
            ...holding,
            value: holdingValue,
            percentage
        };
    }).sort((a, b) => b.value - a.value);
}


// src/utils/formatters.js

/**
 * Format number as currency
 */
export function formatCurrency(amount, decimals = 2) {
    return new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD',
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals
    }).format(amount);
}

/**
 * Format percentage
 */
export function formatPercent(value, decimals = 2) {
    const sign = value > 0 ? '+' : '';
    return `${sign}${value.toFixed(decimals)}%`;
}

/**
 * Format number with appropriate precision
 */
export function formatNumber(num, decimals = 2) {
    return new Intl.NumberFormat('en-US', {
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals
    }).format(num);
}

/**
 * Abbreviate large numbers (1.2M, 3.4B)
 */
export function abbreviateNumber(num) {
    if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B';
    if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
    if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
    return num.toFixed(2);
}

5. React Component Architecture

Main App Component

// src/App.jsx
import React, { useState, useEffect } from 'react';
import PortfolioSummary from './components/PortfolioSummary';
import AddCoinForm from './components/AddCoinForm';
import HoldingsList from './components/HoldingsList';
import PortfolioChart from './components/PortfolioChart';
import coinGeckoAPI from './services/coinGeckoAPI';
import { loadPortfolio, savePortfolio } from './services/portfolioStorage';
import './App.css';

function App() {
    const [holdings, setHoldings] = useState([]);
    const [prices, setPrices] = useState({});
    const [loading, setLoading] = useState(true);

    // Load saved portfolio on mount
    useEffect(() => {
        const savedHoldings = loadPortfolio();
        setHoldings(savedHoldings);
        setLoading(false);
    }, []);

    // Fetch prices for all holdings
    useEffect(() => {
        if (holdings.length === 0) return;

        const fetchPrices = async () => {
            const coinIds = holdings.map(h => h.coinId);
            const priceData = await coinGeckoAPI.getPrices(coinIds);
            setPrices(priceData);
        };

        fetchPrices();

        // Update prices every 60 seconds
        const interval = setInterval(fetchPrices, 60000);
        return () => clearInterval(interval);
    }, [holdings]);

    // Save portfolio whenever it changes
    useEffect(() => {
        if (!loading) {
            savePortfolio(holdings);
        }
    }, [holdings, loading]);

    const addHolding = (newHolding) => {
        setHoldings([...holdings, {
            ...newHolding,
            id: Date.now(),
            addedAt: new Date().toISOString()
        }]);
    };

    const removeHolding = (id) => {
        setHoldings(holdings.filter(h => h.id !== id));
    };

    const updateHolding = (id, updates) => {
        setHoldings(holdings.map(h =>
            h.id === id ? { ...h, ...updates } : h
        ));
    };

    if (loading) {
        return 
Loading portfolio...
; } return (

Crypto Portfolio Tracker

Track your cryptocurrency investments in real-time

); } export default App;

Portfolio Summary Component

// src/components/PortfolioSummary.jsx
import React from 'react';
import { calculateProfitLoss } from '../utils/calculations';
import { formatCurrency, formatPercent } from '../utils/formatters';

function PortfolioSummary({ holdings, prices }) {
    const { currentValue, totalCost, amount, percentage } = calculateProfitLoss(holdings, prices);

    const isProfit = amount >= 0;

    return (
        

Total Value

{formatCurrency(currentValue)}

Total Cost

{formatCurrency(totalCost)}

Profit/Loss

{formatCurrency(amount)}

{formatPercent(percentage)}

Holdings

{holdings.length} Assets

); } export default PortfolioSummary;

Add Coin Form Component

// src/components/AddCoinForm.jsx
import React, { useState } from 'react';
import coinGeckoAPI from '../services/coinGeckoAPI';

function AddCoinForm({ onAdd }) {
    const [searchTerm, setSearchTerm] = useState('');
    const [searchResults, setSearchResults] = useState([]);
    const [selectedCoin, setSelectedCoin] = useState(null);
    const [quantity, setQuantity] = useState('');
    const [purchasePrice, setPurchasePrice] = useState('');
    const [searching, setSearching] = useState(false);

    const handleSearch = async (term) => {
        setSearchTerm(term);

        if (term.length < 2) {
            setSearchResults([]);
            return;
        }

        setSearching(true);
        const results = await coinGeckoAPI.searchCoins(term);
        setSearchResults(results);
        setSearching(false);
    };

    const selectCoin = (coin) => {
        setSelectedCoin(coin);
        setSearchResults([]);
        setSearchTerm(coin.name);
    };

    const handleSubmit = (e) => {
        e.preventDefault();

        if (!selectedCoin || !quantity || !purchasePrice) {
            alert('Please fill all fields');
            return;
        }

        onAdd({
            coinId: selectedCoin.id,
            symbol: selectedCoin.symbol,
            name: selectedCoin.name,
            quantity: parseFloat(quantity),
            purchasePrice: parseFloat(purchasePrice)
        });

        // Reset form
        setSearchTerm('');
        setSelectedCoin(null);
        setQuantity('');
        setPurchasePrice('');
    };

    return (
        

Add Cryptocurrency

{/* Search */}
handleSearch(e.target.value)} placeholder="Bitcoin, Ethereum..." className="form-control" /> {/* Search results dropdown */} {searchResults.length > 0 && (
{searchResults.map(coin => (
selectCoin(coin)} > {coin.name} {coin.name} {coin.symbol}
))}
)}
{/* Quantity */}
setQuantity(e.target.value)} placeholder="0.00" step="0.00000001" min="0" className="form-control" />
{/* Purchase Price */}
setPurchasePrice(e.target.value)} placeholder="0.00" step="0.01" min="0" className="form-control" />
); } export default AddCoinForm;

Holdings List Component

// src/components/HoldingsList.jsx
import React from 'react';
import { calculateHoldingProfitLoss } from '../utils/calculations';
import { formatCurrency, formatPercent, formatNumber } from '../utils/formatters';

function HoldingsList({ holdings, prices, onRemove }) {
    if (holdings.length === 0) {
        return (
            

No holdings yet. Add your first cryptocurrency above!

); } return (

Your Holdings

{holdings.map(holding => { const currentPrice = prices[holding.coinId]?.usd || 0; const priceChange24h = prices[holding.coinId]?.usd_24h_change || 0; const pl = calculateHoldingProfitLoss(holding, currentPrice); return ( ); })}
Asset Quantity Avg Cost Current Price Value Profit/Loss
{holding.symbol} {holding.name}
{formatNumber(holding.quantity, 8)} {formatCurrency(holding.purchasePrice)} {formatCurrency(currentPrice)} = 0 ? 'profit' : 'loss'}> {formatPercent(priceChange24h)} {formatCurrency(pl.currentValue)} = 0 ? 'profit' : 'loss'}> {formatCurrency(pl.profitLoss)} {formatPercent(pl.profitLossPercent)}
); } export default HoldingsList;

6. LocalStorage Persistence

// src/services/portfolioStorage.js

const STORAGE_KEY = 'crypto_portfolio_holdings';

/**
 * Load portfolio from localStorage
 */
export function loadPortfolio() {
    try {
        const saved = localStorage.getItem(STORAGE_KEY);
        return saved ? JSON.parse(saved) : [];
    } catch (error) {
        console.error('Error loading portfolio:', error);
        return [];
    }
}

/**
 * Save portfolio to localStorage
 */
export function savePortfolio(holdings) {
    try {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(holdings));
    } catch (error) {
        console.error('Error saving portfolio:', error);
    }
}

/**
 * Clear saved portfolio
 */
export function clearPortfolio() {
    localStorage.removeItem(STORAGE_KEY);
}

/**
 * Export portfolio as JSON file
 */
export function exportPortfolio(holdings) {
    const dataStr = JSON.stringify(holdings, null, 2);
    const dataBlob = new Blob([dataStr], { type: 'application/json' });
    const url = URL.createObjectURL(dataBlob);

    const link = document.createElement('a');
    link.href = url;
    link.download = `crypto-portfolio-${new Date().toISOString().split('T')[0]}.json`;
    link.click();

    URL.revokeObjectURL(url);
}

/**
 * Import portfolio from JSON file
 */
export function importPortfolio(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();

        reader.onload = (e) => {
            try {
                const holdings = JSON.parse(e.target.result);
                resolve(holdings);
            } catch (error) {
                reject(new Error('Invalid portfolio file'));
            }
        };

        reader.onerror = () => reject(new Error('Failed to read file'));
        reader.readAsText(file);
    });
}

Storage Limits: localStorage has a 5-10MB limit per domain. For larger portfolios or additional features, consider using IndexedDB or cloud storage with user authentication.

7. Charts & Visualizations

Portfolio Allocation Pie Chart

// src/components/PortfolioChart.jsx
import React from 'react';
import { Pie } from 'react-chartjs-2';
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js';
import { calculateAllocation } from '../utils/calculations';

ChartJS.register(ArcElement, Tooltip, Legend);

function PortfolioChart({ holdings, prices }) {
    if (holdings.length === 0) {
        return (
            

Add holdings to see portfolio allocation

); } const allocation = calculateAllocation(holdings, prices); // Generate colors const colors = [ '#f7931a', '#627eea', '#26a17b', '#f3ba2f', '#e84142', '#2775ca', '#8247e5', '#3c3c3d', '#2f80ed', '#56ccf2' ]; const chartData = { labels: allocation.map(h => h.symbol), datasets: [{ data: allocation.map(h => h.value), backgroundColor: colors.slice(0, allocation.length), borderColor: '#ffffff', borderWidth: 2 }] }; const options = { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { generateLabels: (chart) => { const data = chart.data; return data.labels.map((label, i) => ({ text: `${label} (${allocation[i].percentage.toFixed(1)}%)`, fillStyle: data.datasets[0].backgroundColor[i], hidden: false, index: i })); } } }, tooltip: { callbacks: { label: (context) => { const holding = allocation[context.dataIndex]; return [ `${holding.symbol}: $${holding.value.toFixed(2)}`, `${holding.percentage.toFixed(2)}% of portfolio` ]; } } } } }; return (

Portfolio Allocation

); } export default PortfolioChart;

8. Real-Time Price Updates

Custom Hook for Price Updates

// src/hooks/usePriceUpdates.js
import { useState, useEffect, useCallback } from 'react';
import coinGeckoAPI from '../services/coinGeckoAPI';

export function usePriceUpdates(coinIds, updateInterval = 60000) {
    const [prices, setPrices] = useState({});
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    const [lastUpdate, setLastUpdate] = useState(null);

    const fetchPrices = useCallback(async () => {
        if (coinIds.length === 0) {
            setLoading(false);
            return;
        }

        try {
            setError(null);
            const priceData = await coinGeckoAPI.getPrices(coinIds);
            setPrices(priceData);
            setLastUpdate(new Date());
        } catch (err) {
            setError(err.message);
            console.error('Price update error:', err);
        } finally {
            setLoading(false);
        }
    }, [coinIds]);

    // Initial fetch
    useEffect(() => {
        fetchPrices();
    }, [fetchPrices]);

    // Periodic updates
    useEffect(() => {
        if (coinIds.length === 0) return;

        const interval = setInterval(fetchPrices, updateInterval);
        return () => clearInterval(interval);
    }, [coinIds, updateInterval, fetchPrices]);

    const forceUpdate = () => {
        fetchPrices();
    };

    return { prices, loading, error, lastUpdate, forceUpdate };
}


// Usage in App.jsx
import { usePriceUpdates } from './hooks/usePriceUpdates';

function App() {
    const [holdings, setHoldings] = useState([]);
    const coinIds = holdings.map(h => h.coinId);

    const { prices, loading, lastUpdate, forceUpdate } = usePriceUpdates(coinIds, 60000);

    return (
        
{/* Your components */} {lastUpdate && (
Last updated: {lastUpdate.toLocaleTimeString()}
)}
); }

Production Tip: Combine REST API polling (every 60s) with user-triggered refreshes. For ultra-real-time needs, implement WebSocket connections to Binance or Coinbase for instant price updates.

Conclusion

You've built a full-featured cryptocurrency portfolio tracker with React that includes real-time price updates, profit/loss calculations, data persistence, and beautiful visualizations. This foundation can be extended with features like price alerts, historical performance tracking, tax reporting, and multi-user support.

Key technologies used: React for UI, CoinGecko API for price data, Chart.js for visualizations, and localStorage for persistence. The modular architecture makes it easy to add new features or swap out components.

Related Articles

Need Traditional Currency Data?

Extend your portfolio tracker to support fiat currencies with UniRate API. Get exchange rates for 160+ currencies with historical data back to 1999. Perfect for multi-currency applications.

Explore Currency API