React frontend development

How to Build a Real-Time Currency Converter in React

Currency conversion is one of those features that sounds simple until you start building it. You need live exchange rates, a clean interface for selecting currencies, instant feedback when users type an amount, and graceful handling when the network goes down. This tutorial walks you through building all of that from scratch in React, using the Exchange Rate API as the data source.

By the end, you will have a polished currency converter that fetches real-time rates, lets users swap between any supported currency pair, and displays a historical rate chart.

Prerequisites

Before you start, make sure you have:

Your API key will look like this: era_live_abc123.... Keep it handy.

Step 1: Project Setup

Scaffold a new React project using Vite:

npm create vite@latest currency-converter -- --template react
cd currency-converter
npm install

Create a .env file in the project root to store your API key:

VITE_EXCHANGE_RATE_API_KEY=era_live_your_key_here

Vite exposes environment variables prefixed with VITE_ to your client code.

Step 2: Install the SDK

Install the official Exchange Rate API JavaScript SDK:

npm install @exchangerateapi/sdk

This package provides a typed client with methods for fetching rates, converting currencies, listing symbols, and pulling historical data.

Step 3: Create the API Client

Create src/api/exchangeRate.js to initialize a shared SDK client:

import { ExchangeRateAPI } from "@exchangerateapi/sdk";

const client = new ExchangeRateAPI({
  apiKey: import.meta.env.VITE_EXCHANGE_RATE_API_KEY,
});

export default client;

Step 4: Build the Currency Selector

Create a reusable dropdown that loads all supported currencies. Save it as src/components/CurrencySelector.jsx:

import { useState, useEffect } from "react";
import client from "../api/exchangeRate";

function CurrencySelector({ value, onChange, label }) {
  const [currencies, setCurrencies] = useState({});
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    async function loadSymbols() {
      try {
        const { symbols } = await client.symbols();
        if (!cancelled) { setCurrencies(symbols); setLoading(false); }
      } catch (err) {
        console.error("Failed to load currencies:", err.message);
        if (!cancelled) setLoading(false);
      }
    }
    loadSymbols();
    return () => { cancelled = true; };
  }, []);

  if (loading) {
    return (
      <div className="selector">
        <label>{label}</label>
        <select disabled><option>Loading...</option></select>
      </div>
    );
  }

  return (
    <div className="selector">
      <label>{label}</label>
      <select value={value} onChange={(e) => onChange(e.target.value)}>
        {Object.entries(currencies)
          .sort(([a], [b]) => a.localeCompare(b))
          .map(([code, name]) => (
            <option key={code} value={code}>{code} - {name}</option>
          ))}
      </select>
    </div>
  );
}

export default CurrencySelector;

The client.symbols() method returns an object like { USD: "United States Dollar", EUR: "Euro", ... }. The cancelled flag prevents state updates if the component unmounts before the API responds.

Step 5: Build the Conversion Hook

Encapsulate the conversion logic in a custom hook at src/hooks/useConversion.js. It debounces API calls, retries on failure, and translates error codes into user-friendly messages:

import { useState, useEffect, useCallback, useRef } from "react";
import client from "../api/exchangeRate";

export function useConversion(from, to, amount) {
  const [result, setResult] = useState(null);
  const [rate, setRate] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const retryCount = useRef(0);

  const convert = useCallback(async () => {
    const numericAmount = parseFloat(amount);
    if (!numericAmount || numericAmount <= 0 || !from || !to) {
      setResult(null); setRate(null); return;
    }
    setLoading(true); setError(null);
    try {
      const data = await client.convert(from, to, numericAmount);
      setResult(data.result);
      setRate(data.rate);
      retryCount.current = 0;
    } catch (err) {
      if (retryCount.current < 2) {
        retryCount.current += 1;
        setTimeout(convert, 1000 * retryCount.current);
        return;
      }
      const message =
        err.status === 429 ? "Rate limit reached. Please wait a moment."
        : err.status === 401 ? "Invalid API key. Check your configuration."
        : "Unable to fetch conversion rate. Please try again.";
      setError(message); setResult(null); setRate(null);
      retryCount.current = 0;
    } finally {
      setLoading(false);
    }
  }, [from, to, amount]);

  useEffect(() => {
    const timer = setTimeout(convert, 400);
    return () => clearTimeout(timer);
  }, [convert]);

  return { result, rate, loading, error };
}

The 400ms setTimeout prevents firing a request on every keystroke. On transient failures, the hook retries up to twice with increasing delays before showing an error.

Step 6: Build the Converter Component

Create src/components/CurrencyConverter.jsx, tying together the selector, hook, and UI:

import { useState } from "react";
import CurrencySelector from "./CurrencySelector";
import { useConversion } from "../hooks/useConversion";

function CurrencyConverter() {
  const [fromCurrency, setFromCurrency] = useState("USD");
  const [toCurrency, setToCurrency] = useState("EUR");
  const [amount, setAmount] = useState("1");

  const { result, rate, loading, error } = useConversion(fromCurrency, toCurrency, amount);

  function handleSwap() {
    setFromCurrency(toCurrency);
    setToCurrency(fromCurrency);
  }

  function handleAmountChange(e) {
    const value = e.target.value;
    if (value === "" || /^\d*\.?\d*$/.test(value)) setAmount(value);
  }

  return (
    <div className="converter">
      <h1>Currency Converter</h1>
      <div className="converter-form">
        <div className="amount-group">
          <label>Amount</label>
          <input type="text" value={amount} onChange={handleAmountChange}
            placeholder="Enter amount" className="amount-input" />
        </div>
        <div className="currency-row">
          <CurrencySelector value={fromCurrency} onChange={setFromCurrency} label="From" />
          <button className="swap-button" onClick={handleSwap} title="Swap currencies">&#8646;</button>
          <CurrencySelector value={toCurrency} onChange={setToCurrency} label="To" />
        </div>
      </div>
      <div className="result-section">
        {loading && <p className="loading-text">Converting...</p>}
        {error && <p className="error-text">{error}</p>}
        {result !== null && !loading && !error && (
          <div className="result-display">
            <p className="result-amount">
              {parseFloat(amount).toLocaleString()} {fromCurrency} ={" "}
              <strong>{result.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })}</strong>{" "}
              {toCurrency}
            </p>
            <p className="rate-info">1 {fromCurrency} = {rate} {toCurrency}</p>
          </div>
        )}
      </div>
    </div>
  );
}

export default CurrencyConverter;

The input validates against /^\d*\.?\d*$/ to allow only numeric values. Swapping currencies flips both state values, which triggers a fresh conversion automatically via the hook.

Step 7: Add Styling

Replace the contents of src/App.css. The design centers a white card on a light grey background with a blue accent color for focus states and results:

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #f5f7fa; color: #1a1a2e;
  min-height: 100vh; display: flex; justify-content: center; padding: 60px 20px;
}

.converter {
  background: #fff; border-radius: 12px; padding: 40px;
  max-width: 520px; width: 100%;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}

.converter h1 { font-size: 1.6rem; margin-bottom: 28px; text-align: center; }
.amount-group { margin-bottom: 20px; }

.amount-group label, .selector label {
  display: block; font-size: 0.85rem; font-weight: 600;
  color: #555; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px;
}

.amount-input, .selector select {
  width: 100%; padding: 12px 16px; border: 2px solid #e0e0e0;
  border-radius: 8px; outline: none; transition: border-color 0.2s;
}

.amount-input { font-size: 1.1rem; }
.selector select { font-size: 0.95rem; background: #fff; cursor: pointer; }
.amount-input:focus, .selector select:focus { border-color: #4a6cf7; }
.currency-row { display: flex; align-items: flex-end; gap: 12px; }
.selector { flex: 1; }

.swap-button {
  padding: 12px 14px; font-size: 1.3rem; border: 2px solid #e0e0e0;
  border-radius: 8px; background: #fff; cursor: pointer; flex-shrink: 0;
}
.swap-button:hover { background: #f0f0f0; border-color: #4a6cf7; }

.result-section { margin-top: 28px; padding-top: 24px; border-top: 1px solid #eee; text-align: center; }
.result-amount { font-size: 1.2rem; margin-bottom: 8px; }
.result-amount strong { color: #4a6cf7; font-size: 1.4rem; }
.rate-info { font-size: 0.85rem; color: #888; }
.loading-text { color: #888; font-style: italic; }
.error-text { color: #e74c3c; font-weight: 500; }

.chart-section { margin-top: 32px; }
.chart-section h2 { font-size: 1.1rem; margin-bottom: 16px; color: #333; }
.chart-container { width: 100%; height: 200px; display: flex; align-items: flex-end; gap: 2px; }

.chart-bar-wrapper {
  flex: 1; display: flex; flex-direction: column; align-items: center;
  height: 100%; justify-content: flex-end;
}

.chart-bar { width: 100%; background: #4a6cf7; border-radius: 3px 3px 0 0; min-height: 2px; }
.chart-label { font-size: 0.55rem; color: #aaa; margin-top: 4px; transform: rotate(-45deg); }

Step 8: Bonus -- Historical Rate Chart

Add a bar chart showing the exchange rate trend over the past 30 days. Create src/components/RateChart.jsx:

import { useState, useEffect } from "react";
import client from "../api/exchangeRate";

function getDateString(offset) {
  const d = new Date();
  d.setDate(d.getDate() + offset);
  return d.toISOString().split("T")[0];
}

function RateChart({ from, to }) {
  const [chartData, setChartData] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    async function loadHistory() {
      setLoading(true); setError(null);
      try {
        const data = await client.timeSeries(getDateString(-30), getDateString(0), {
          base: from, symbols: [to],
        });
        if (cancelled) return;
        const points = Object.entries(data.rates)
          .sort(([a], [b]) => a.localeCompare(b))
          .map(([date, rates]) => ({ date, rate: rates[to] }));
        setChartData(points);
      } catch (err) {
        if (!cancelled) setError("Unable to load historical rates.");
      } finally {
        if (!cancelled) setLoading(false);
      }
    }
    loadHistory();
    return () => { cancelled = true; };
  }, [from, to]);

  if (loading) return <div className="chart-loading">Loading chart...</div>;
  if (error) return <div className="chart-error">{error}</div>;
  if (chartData.length === 0) return null;

  const rates = chartData.map((d) => d.rate);
  const min = Math.min(...rates), max = Math.max(...rates);
  const range = max - min || 1;

  return (
    <div className="chart-section">
      <h2>{from}/{to} -- Last 30 Days</h2>
      <div className="chart-container">
        {chartData.map((pt, i) => (
          <div key={i} className="chart-bar-wrapper" title={`${pt.date}: ${pt.rate}`}>
            <div className="chart-bar" style={{ height: `${((pt.rate - min) / range) * 160 + 20}px` }} />
            {i % 5 === 0 && <span className="chart-label">{pt.date.slice(5)}</span>}
          </div>
        ))}
      </div>
    </div>
  );
}

export default RateChart;

The client.timeSeries() method accepts a start date, end date, and an options object with base and symbols. It returns rates keyed by date, with each value mapping currency codes to their rate. The chart normalizes these values to pixel heights and renders one bar per day.

To add the chart to the converter, import RateChart in CurrencyConverter.jsx and place it after the result section:

import RateChart from "./RateChart";

// Add inside the return, after the result-section div:
{result !== null && !error && (
  <RateChart from={fromCurrency} to={toCurrency} />
)}

Step 9: Wire Up the App

Update src/App.jsx:

import CurrencyConverter from "./components/CurrencyConverter";
import "./App.css";

function App() {
  return <CurrencyConverter />;
}

export default App;

Start the development server:

npm run dev

Open http://localhost:5173 in your browser. You should see the converter load currencies, display a default USD to EUR conversion, and respond to input changes in real time.

Project Structure

Here is the final file layout:

currency-converter/
  .env
  src/
    App.jsx                        # Step 9
    App.css                        # Step 7
    api/exchangeRate.js            # Step 3
    hooks/useConversion.js         # Step 5
    components/
      CurrencySelector.jsx         # Step 4
      CurrencyConverter.jsx        # Step 6 + 8
      RateChart.jsx                # Step 8

The SDK methods used in this project:

Method Purpose Step
client.symbols() Load all supported currency codes and names 4
client.convert(from, to, amount) Convert an amount between two currencies 5
client.timeSeries(start, end, opts) Fetch historical rates for a date range 8
client.latest({ base, symbols }) Get latest rates for a base currency Next Steps

What You Have Built

This tutorial covered every layer of a production-ready currency converter: a shared API client using @exchangerateapi/sdk, a custom hook with debouncing and retry logic, reusable UI components populated from live data, and a 30-day historical rate chart -- all with proper error handling and input validation throughout.

Next Steps

Get Started

Sign up for a free API key at exchange-rateapi.com/register to start building. The free tier gives you 1,500 requests per month. If you run into any issues, the API documentation covers every endpoint and SDK method in detail.

Get Started for Free

Real-time mid-market rates, historical data, 160+ currencies. Official SDKs for JavaScript, Python, PHP, and React.

Get Your Free API Key →

Related Articles