Build a React Currency Converter with Real-Time RBA Exchange Rates API

Create a modern, responsive currency converter in React using official Reserve Bank of Australia exchange rate data. Includes hooks, error handling, and a polished UI you can deploy immediately.

Building a currency converter is one of the best ways to learn API integration in React. Using the Reserve Bank of Australia's official exchange rate data, we'll create a production-ready converter with modern React patterns, responsive design, and real-time updates.

What We're Building

  • Modern React app with hooks and functional components
  • Real-time currency conversion with RBA exchange rates
  • Historical rate comparison feature
  • Responsive design that works on all devices
  • Error handling and loading states
  • Local storage for user preferences

Project Setup

Create a new React app and install dependencies:

npx create-react-app rba-currency-converter
cd rba-currency-converter
npm install axios date-fns
npm start

We'll use axios for API calls and date-fns for date formatting.

Prefer other technologies? We also have tutorials for Python backend integration and WordPress/PHP implementations.

API Integration Hook

First, let's create a custom hook for the RBA Exchange Rates API:

// hooks/useExchangeRates.js
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';

const API_BASE = 'https://api.exchangeratesapi.com.au';

export const useExchangeRates = (apiKey) => {
  const [rates, setRates] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [lastUpdated, setLastUpdated] = useState(null);

  const headers = {
    Authorization: `Bearer ${apiKey}`
  };

  const fetchLatestRates = useCallback(async () => {
    setLoading(true);
    setError(null);
    
    try {
      const response = await axios.get(`${API_BASE}/latest`, { headers });
      
      if (response.data.success) {
        setRates(response.data);
        setLastUpdated(new Date());
      } else {
        setError(response.data.error?.info || 'Failed to fetch rates');
      }
    } catch (err) {
      if (err.response?.status === 401) {
        setError('Invalid API key. Please check your credentials.');
      } else if (err.response?.status === 429) {
        setError('Rate limit exceeded. Please try again later.');
      } else {
        setError('Network error. Please check your connection.');
      }
    } finally {
      setLoading(false);
    }
  }, [apiKey, headers]);

  const convertCurrency = useCallback(async (from, to, amount, date = null) => {
    setLoading(true);
    setError(null);
    
    try {
      const params = { from, to, amount };
      if (date) params.date = date;
      
      const response = await axios.get(`${API_BASE}/convert`, { 
        headers, 
        params 
      });
      
      if (response.data.success) {
        return response.data;
      } else {
        setError(response.data.error?.info || 'Conversion failed');
        return null;
      }
    } catch (err) {
      setError('Conversion request failed');
      return null;
    } finally {
      setLoading(false);
    }
  }, [apiKey, headers]);

  const getHistoricalRates = useCallback(async (date) => {
    setLoading(true);
    setError(null);
    
    try {
      const response = await axios.get(`${API_BASE}/${date}`, { headers });
      
      if (response.data.success) {
        return response.data;
      } else {
        setError(response.data.error?.info || 'Historical data not available');
        return null;
      }
    } catch (err) {
      setError('Failed to fetch historical data');
      return null;
    } finally {
      setLoading(false);
    }
  }, [apiKey, headers]);

  return {
    rates,
    loading,
    error,
    lastUpdated,
    fetchLatestRates,
    convertCurrency,
    getHistoricalRates
  };
};

Main Currency Converter Component

// components/CurrencyConverter.js
import React, { useState, useEffect } from 'react';
import { useExchangeRates } from '../hooks/useExchangeRates';
import { format } from 'date-fns';
import './CurrencyConverter.css';

const SUPPORTED_CURRENCIES = {
  'AUD': 'Australian Dollar',
  'USD': 'US Dollar',
  'EUR': 'Euro',
  'GBP': 'British Pound',
  'JPY': 'Japanese Yen',
  'CAD': 'Canadian Dollar',
  'CHF': 'Swiss Franc',
  'CNY': 'Chinese Renminbi',
  'NZD': 'New Zealand Dollar',
  'SGD': 'Singapore Dollar',
  'HKD': 'Hong Kong Dollar',
  'KRW': 'South Korean Won'
};

const CurrencyConverter = () => {
  const [apiKey, setApiKey] = useState(
    localStorage.getItem('rba-api-key') || ''
  );
  const [fromCurrency, setFromCurrency] = useState('AUD');
  const [toCurrency, setToCurrency] = useState('USD');
  const [amount, setAmount] = useState(100);
  const [result, setResult] = useState(null);
  const [historicalDate, setHistoricalDate] = useState('');
  const [showHistorical, setShowHistorical] = useState(false);
  const [historicalResult, setHistoricalResult] = useState(null);
  
  const {
    rates,
    loading,
    error,
    lastUpdated,
    fetchLatestRates,
    convertCurrency,
    getHistoricalRates
  } = useExchangeRates(apiKey);

  // Save API key to localStorage
  useEffect(() => {
    if (apiKey) {
      localStorage.setItem('rba-api-key', apiKey);
    }
  }, [apiKey]);

  // Load initial rates
  useEffect(() => {
    if (apiKey && apiKey.length > 20) {
      fetchLatestRates();
    }
  }, [apiKey, fetchLatestRates]);

  const handleConvert = async (e) => {
    e.preventDefault();
    
    if (!apiKey) {
      alert('Please enter your API key');
      return;
    }

    const conversionResult = await convertCurrency(fromCurrency, toCurrency, amount);
    setResult(conversionResult);
  };

  const handleHistoricalCompare = async () => {
    if (!historicalDate) return;
    
    const historical = await convertCurrency(fromCurrency, toCurrency, amount, historicalDate);
    setHistoricalResult(historical);
  };

  const swapCurrencies = () => {
    setFromCurrency(toCurrency);
    setToCurrency(fromCurrency);
    setResult(null);
    setHistoricalResult(null);
  };

  const formatCurrency = (value, currency) => {
    return new Intl.NumberFormat('en-AU', {
      style: 'currency',
      currency: currency === 'AUD' ? 'AUD' : 'USD',
      minimumFractionDigits: 2,
      maximumFractionDigits: 6
    }).format(value);
  };

  if (!apiKey) {
    return (
      <div className="converter-container">
        <div className="api-key-setup">
          <h2>RBA Currency Converter</h2>
          <p>Enter your API key to get started:</p>
          <input
            type="password"
            placeholder="Enter your API key"
            value={apiKey}
            onChange={(e) => setApiKey(e.target.value)}
            className="api-key-input"
          />
          <p className="api-help">
            Get your free API key at{' '}
            <a href="https://app.exchangeratesapi.com.au" target="_blank" rel="noopener noreferrer">
              app.exchangeratesapi.com.au
            </a>
          </p>
        </div>
      </div>
    );
  }

  return (
    <div className="converter-container">
      <header className="converter-header">
        <h1>RBA Currency Converter</h1>
        <p>Official Reserve Bank of Australia Exchange Rates</p>
        {lastUpdated && (
          <p className="last-updated">
            Last updated: {format(lastUpdated, 'PPpp')}
          </p>
        )}
      </header>

      <form onSubmit={handleConvert} className="converter-form">
        <div className="amount-input">
          <label htmlFor="amount">Amount</label>
          <input
            id="amount"
            type="number"
            value={amount}
            onChange={(e) => setAmount(parseFloat(e.target.value) || 0)}
            min="0"
            step="0.01"
          />
        </div>

        <div className="currency-selectors">
          <div className="currency-input">
            <label htmlFor="from-currency">From</label>
            <select
              id="from-currency"
              value={fromCurrency}
              onChange={(e) => setFromCurrency(e.target.value)}
            >
              {Object.entries(SUPPORTED_CURRENCIES).map(([code, name]) => (
                <option key={code} value={code}>
                  {code} - {name}
                </option>
              ))}
            </select>
          </div>

          <button
            type="button"
            onClick={swapCurrencies}
            className="swap-button"
            title="Swap currencies"
          >
            ⇄
          </button>

          <div className="currency-input">
            <label htmlFor="to-currency">To</label>
            <select
              id="to-currency"
              value={toCurrency}
              onChange={(e) => setToCurrency(e.target.value)}
            >
              {Object.entries(SUPPORTED_CURRENCIES).map(([code, name]) => (
                <option key={code} value={code}>
                  {code} - {name}
                </option>
              ))}
            </select>
          </div>
        </div>

        <button type="submit" disabled={loading} className="convert-button">
          {loading ? 'Converting...' : 'Convert'}
        </button>
      </form>

      {error && (
        <div className="error-message">
          <strong>Error:</strong> {error}
        </div>
      )}

      {result && (
        <div className="result-container">
          <div className="conversion-result">
            <div className="result-amount">
              {formatCurrency(amount, fromCurrency)} = {formatCurrency(result.result, toCurrency)}
            </div>
            <div className="exchange-rate">
              Exchange Rate: 1 {fromCurrency} = {result.info?.rate.toFixed(6)} {toCurrency}
            </div>
            <div className="result-date">
              Rate from: {result.date}
            </div>
          </div>

          <div className="historical-section">
            <button
              onClick={() => setShowHistorical(!showHistorical)}
              className="historical-toggle"
            >
              {showHistorical ? 'Hide' : 'Show'} Historical Comparison
            </button>

            {showHistorical && (
              <div className="historical-compare">
                <div className="date-input">
                  <label htmlFor="historical-date">Compare with date:</label>
                  <input
                    id="historical-date"
                    type="date"
                    value={historicalDate}
                    onChange={(e) => setHistoricalDate(e.target.value)}
                    min="2018-01-01"
                    max={new Date().toISOString().split('T')[0]}
                  />
                  <button
                    type="button"
                    onClick={handleHistoricalCompare}
                    disabled={!historicalDate || loading}
                    className="compare-button"
                  >
                    Compare
                  </button>
                </div>

                {historicalResult && (
                  <div className="historical-result">
                    <h4>Historical Comparison</h4>
                    <div className="comparison-row">
                      <span>Today:</span>
                      <span>{formatCurrency(result.result, toCurrency)}</span>
                    </div>
                    <div className="comparison-row">
                      <span>{historicalResult.date}:</span>
                      <span>{formatCurrency(historicalResult.result, toCurrency)}</span>
                    </div>
                    <div className="comparison-difference">
                      Difference: {formatCurrency(
                        result.result - historicalResult.result, 
                        toCurrency
                      )} ({((result.result - historicalResult.result) / historicalResult.result * 100).toFixed(2)}%)
                    </div>
                  </div>
                )}
              </div>
            )}
          </div>
        </div>
      )}

      {rates && (
        <div className="rates-grid">
          <h3>Current Exchange Rates (1 AUD =)</h3>
          <div className="rates-container">
            {Object.entries(rates.rates).map(([currency, rate]) => (
              <div key={currency} className="rate-card">
                <div className="rate-currency">{currency}</div>
                <div className="rate-value">{rate.toFixed(4)}</div>
              </div>
            ))}
          </div>
        </div>
      )}

      <footer className="converter-footer">
        <p>Data sourced from Reserve Bank of Australia. Not affiliated with or endorsed by the RBA.</p>
      </footer>
    </div>
  );
};

export default CurrencyConverter;

Styling

Create a modern, responsive stylesheet:

/* components/CurrencyConverter.css */
.converter-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
}

.converter-header {
  text-align: center;
  color: white;
  margin-bottom: 30px;
}

.converter-header h1 {
  font-size: 2.5rem;
  margin-bottom: 10px;
  font-weight: 300;
}

.converter-header p {
  font-size: 1.1rem;
  opacity: 0.9;
  margin-bottom: 5px;
}

.last-updated {
  font-size: 0.9rem !important;
  opacity: 0.7 !important;
}

.api-key-setup {
  background: white;
  border-radius: 12px;
  padding: 40px;
  text-align: center;
  box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}

.api-key-input {
  width: 100%;
  max-width: 400px;
  padding: 12px;
  border: 2px solid #e1e5e9;
  border-radius: 6px;
  font-size: 16px;
  margin: 20px 0;
}

.api-help {
  color: #666;
  font-size: 14px;
}

.api-help a {
  color: #667eea;
  text-decoration: none;
}

.converter-form {
  background: white;
  border-radius: 12px;
  padding: 30px;
  margin-bottom: 20px;
  box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}

.amount-input {
  margin-bottom: 25px;
}

.amount-input label {
  display: block;
  margin-bottom: 8px;
  font-weight: 600;
  color: #333;
}

.amount-input input {
  width: 100%;
  padding: 12px;
  border: 2px solid #e1e5e9;
  border-radius: 6px;
  font-size: 18px;
  font-weight: 500;
}

.currency-selectors {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  gap: 15px;
  align-items: end;
  margin-bottom: 25px;
}

.currency-input label {
  display: block;
  margin-bottom: 8px;
  font-weight: 600;
  color: #333;
}

.currency-input select {
  width: 100%;
  padding: 12px;
  border: 2px solid #e1e5e9;
  border-radius: 6px;
  font-size: 16px;
  background: white;
}

.swap-button {
  background: #f8f9fa;
  border: 2px solid #e1e5e9;
  border-radius: 50%;
  width: 45px;
  height: 45px;
  font-size: 20px;
  cursor: pointer;
  transition: all 0.2s ease;
  display: flex;
  align-items: center;
  justify-content: center;
}

.swap-button:hover {
  background: #667eea;
  color: white;
  border-color: #667eea;
}

.convert-button {
  width: 100%;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border: none;
  padding: 15px;
  border-radius: 6px;
  font-size: 18px;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.2s ease;
}

.convert-button:hover:not(:disabled) {
  transform: translateY(-2px);
}

.convert-button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.error-message {
  background: #fee;
  color: #c33;
  padding: 15px;
  border-radius: 6px;
  margin-bottom: 20px;
  border: 1px solid #fcc;
}

.result-container {
  background: white;
  border-radius: 12px;
  padding: 30px;
  margin-bottom: 20px;
  box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}

.conversion-result {
  text-align: center;
  margin-bottom: 30px;
}

.result-amount {
  font-size: 2rem;
  font-weight: 600;
  color: #333;
  margin-bottom: 10px;
}

.exchange-rate {
  font-size: 1.1rem;
  color: #666;
  margin-bottom: 5px;
}

.result-date {
  font-size: 0.9rem;
  color: #888;
}

.historical-toggle {
  background: #f8f9fa;
  border: 1px solid #e1e5e9;
  padding: 10px 20px;
  border-radius: 6px;
  cursor: pointer;
  margin-bottom: 15px;
}

.historical-compare {
  border-top: 1px solid #e1e5e9;
  padding-top: 20px;
}

.date-input {
  display: flex;
  gap: 10px;
  align-items: center;
  margin-bottom: 20px;
  flex-wrap: wrap;
}

.date-input label {
  font-weight: 600;
  white-space: nowrap;
}

.date-input input {
  padding: 8px;
  border: 2px solid #e1e5e9;
  border-radius: 4px;
}

.compare-button {
  background: #28a745;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}

.historical-result {
  background: #f8f9fa;
  padding: 20px;
  border-radius: 6px;
}

.comparison-row {
  display: flex;
  justify-content: space-between;
  margin-bottom: 10px;
}

.comparison-difference {
  font-weight: 600;
  color: #667eea;
  border-top: 1px solid #e1e5e9;
  padding-top: 10px;
  margin-top: 10px;
  text-align: center;
}

.rates-grid {
  background: white;
  border-radius: 12px;
  padding: 30px;
  margin-bottom: 20px;
  box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}

.rates-grid h3 {
  text-align: center;
  margin-bottom: 20px;
  color: #333;
}

.rates-container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: 15px;
}

.rate-card {
  background: #f8f9fa;
  padding: 15px;
  border-radius: 6px;
  text-align: center;
  border: 1px solid #e1e5e9;
}

.rate-currency {
  font-weight: 600;
  color: #333;
  margin-bottom: 5px;
}

.rate-value {
  font-size: 1.1rem;
  color: #667eea;
  font-weight: 500;
}

.converter-footer {
  text-align: center;
  color: white;
  opacity: 0.8;
  font-size: 0.9rem;
  margin-top: 30px;
}

/* Responsive Design */
@media (max-width: 768px) {
  .converter-container {
    padding: 15px;
  }
  
  .converter-header h1 {
    font-size: 2rem;
  }
  
  .currency-selectors {
    grid-template-columns: 1fr;
    gap: 15px;
  }
  
  .swap-button {
    order: 3;
    justify-self: center;
    margin: 10px 0;
  }
  
  .result-amount {
    font-size: 1.5rem;
  }
  
  .rates-container {
    grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
    gap: 10px;
  }
  
  .date-input {
    flex-direction: column;
    align-items: stretch;
  }
}

App Component

Update your main App component:

// App.js
import React from 'react';
import CurrencyConverter from './components/CurrencyConverter';
import './App.css';

function App() {
  return (
    <div className="App">
      <CurrencyConverter />
    </div>
  );
}

export default App;
/* App.css */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.App {
  min-height: 100vh;
}

Advanced Features

Real-time Updates

Add automatic rate refreshing:

// Add to useExchangeRates hook
const [autoRefresh, setAutoRefresh] = useState(false);

useEffect(() => {
  if (!autoRefresh || !apiKey) return;
  
  const interval = setInterval(() => {
    fetchLatestRates();
  }, 300000); // Refresh every 5 minutes
  
  return () => clearInterval(interval);
}, [autoRefresh, apiKey, fetchLatestRates]);

Currency Favorites

Add user preferences:

// Add to CurrencyConverter component
const [favorites, setFavorites] = useState(() => {
  const saved = localStorage.getItem('currency-favorites');
  return saved ? JSON.parse(saved) : ['USD', 'EUR', 'GBP'];
});

const toggleFavorite = (currency) => {
  const newFavorites = favorites.includes(currency)
    ? favorites.filter(c => c !== currency)
    : [...favorites, currency];
  
  setFavorites(newFavorites);
  localStorage.setItem('currency-favorites', JSON.stringify(newFavorites));
};

Chart Integration

Add a simple rate trend chart. For server-side chart generation, see our Python tutorial which includes matplotlib examples:

npm install chart.js react-chartjs-2
// components/RateChart.js
import React, { useState, useEffect } from 'react';
import {
  Chart as ChartJS,
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  Title,
  Tooltip,
  Legend,
} from 'chart.js';
import { Line } from 'react-chartjs-2';

ChartJS.register(
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  Title,
  Tooltip,
  Legend
);

const RateChart = ({ currency, rates, getHistoricalRates }) => {
  const [chartData, setChartData] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const fetchChartData = async () => {
      setLoading(true);
      const dates = [];
      const values = [];
      
      // Get last 30 days
      for (let i = 29; i >= 0; i--) {
        const date = new Date();
        date.setDate(date.getDate() - i);
        const dateStr = date.toISOString().split('T')[0];
        
        try {
          const historical = await getHistoricalRates(dateStr);
          if (historical?.rates?.[currency]) {
            dates.push(dateStr);
            values.push(historical.rates[currency]);
          }
        } catch (error) {
          console.log(`No data for ${dateStr}`);
        }
      }
      
      setChartData({
        labels: dates,
        datasets: [{
          label: `AUD to ${currency}`,
          data: values,
          borderColor: '#667eea',
          backgroundColor: 'rgba(102, 126, 234, 0.1)',
          tension: 0.4
        }]
      });
      setLoading(false);
    };

    if (currency && getHistoricalRates) {
      fetchChartData();
    }
  }, [currency, getHistoricalRates]);

  if (loading) return <div>Loading chart...</div>;
  if (!chartData) return null;

  return (
    <div style={{ height: '400px', marginTop: '20px' }}>
      <Line 
        data={chartData}
        options={{
          responsive: true,
          maintainAspectRatio: false,
          plugins: {
            legend: { position: 'top' },
            title: { 
              display: true, 
              text: `30-Day Exchange Rate Trend` 
            },
          },
        }}
      />
    </div>
  );
};

export default RateChart;

Deployment

Deploy to Vercel, Netlify, or any static hosting:

# Build for production
npm run build

# Deploy to Vercel
npx vercel --prod

# Or deploy to Netlify
npm install -g netlify-cli
netlify deploy --prod --dir=build

Why React + RBA API?

This combination gives you:

  1. Real-time Updates: React's state management handles live data perfectly
  2. Official Data: RBA rates ensure accuracy for Australian businesses
  3. Modern UX: Responsive design works on all devices
  4. Extensible: Easy to add charts, notifications, and integrations
  5. SEO Friendly: Can be server-side rendered with Next.js

Perfect for embedding in business dashboards, e-commerce sites, or financial applications where accurate AUD exchange rates are critical.

Next Steps

Enhance your converter with:

  • Next.js for server-side rendering and better SEO
  • PWA features for offline functionality
  • Push notifications for rate alerts
  • Integration with payment processors
  • Multi-language support for international users

For backend API integration, check out our Python implementation with Flask and database caching. WordPress users should explore our WordPress plugin tutorial for easy shortcode integration and WooCommerce support.

The official RBA data ensures compliance with Australian financial regulations, making this ideal for business applications.


We are not affiliated with or endorsed by the Reserve Bank of Australia.

For API documentation, visit Exchange Rates API Docs