How to Build a Currency Converter with React and a Live Exchange Rate API
Building a currency converter with React is an excellent project for developers who want to practice API integration, React hooks, and real-world state management. In this step-by-step tutorial, you'll build a fully functional real-time currency converter that fetches live exchange rates from a currency API, supports 170+ currencies, and handles errors gracefully — all using modern React patterns.
Whether you're adding multi-currency support to an existing app or building a standalone converter tool, this guide covers everything you need. If you're new to how exchange rates work under the hood, check out our exchange rates explainer before diving in.
What You'll Build
By the end of this tutorial, you'll have a React currency converter that:
- Fetches live exchange rates from the Finexly API
- Supports 170+ currencies via a searchable dropdown
- Converts amounts in real time as the user types (with debouncing)
- Handles loading states and API errors gracefully
- Displays the rate and last-updated timestamp
Here's a preview of the final component structure:
currency-converter/
├── src/
│ ├── components/
│ │ └── CurrencyConverter.tsx
│ ├── hooks/
│ │ └── useExchangeRate.ts
│ ├── App.tsx
│ └── main.tsx
├── package.json
└── vite.config.tsPrerequisites
Before you start, make sure you have:
- Node.js 18+ installed
- Basic familiarity with React hooks (
useState,useEffect) - A free Finexly API key (takes about 30 seconds to get)
You don't need to know TypeScript — but the examples use it because type safety makes API integration much cleaner. You can strip the types if you prefer plain JavaScript.
Step 1: Set Up Your React Project
We'll use Vite as our build tool because it's fast, modern, and the current standard for React development.
npm create vite@latest currency-converter -- --template react-ts
cd currency-converter
npm install
npm run devYour dev server will start at http://localhost:5173. You should see the default Vite + React landing page.
Step 2: Understand the Finexly API
The Finexly API provides real-time and historical exchange rates for 170+ currencies via a simple REST interface. Here's the endpoint we'll use:
GET https://finexly.com/api/v1/latest?base=USD&symbols=EUR,GBP,JPYOr to get all available rates for a base currency:
GET https://finexly.com/api/v1/latest?base=USDA typical response looks like this:
{
"base": "USD",
"date": "2026-04-07",
"rates": {
"EUR": 0.9182,
"GBP": 0.7871,
"JPY": 151.42,
"CAD": 1.3621,
"AUD": 1.5234
},
"timestamp": 1744012800
}To use the API, include your key in the request header:
Authorization: Bearer YOUR_API_KEYYou can sign up for a free plan that includes 1,000 requests per month — enough for a personal project or prototype.
Step 3: Create the Custom Hook
Good React architecture separates data fetching from UI rendering. Let's create a custom hook useExchangeRate that handles all the API logic.
Create the file src/hooks/useExchangeRate.ts:
import { useState, useEffect, useCallback } from "react";
interface ExchangeRateResult {
rates: Record<string, number> | null;
loading: boolean;
error: string | null;
lastUpdated: Date | null;
}
const API_KEY = import.meta.env.VITE_FINEXLY_API_KEY;
const BASE_URL = "https://finexly.com/api/v1";
// Simple in-memory cache: base currency → { rates, timestamp }
const rateCache: Record<string, { rates: Record<string, number>; timestamp: number }> = {};
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
export function useExchangeRate(baseCurrency: string): ExchangeRateResult {
const [rates, setRates] = useState<Record<string, number> | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const fetchRates = useCallback(async (base: string) => {
// Check cache first
const cached = rateCache[base];
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
setRates(cached.rates);
setLastUpdated(new Date(cached.timestamp));
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch(`${BASE_URL}/latest?base=${base}`, {
headers: {
Authorization: `Bearer ${API_KEY}`,
},
});
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
}
const data = await res.json();
// Store in cache
rateCache[base] = { rates: data.rates, timestamp: Date.now() };
setRates(data.rates);
setLastUpdated(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch exchange rates");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (baseCurrency) {
fetchRates(baseCurrency);
}
}, [baseCurrency, fetchRates]);
return { rates, loading, error, lastUpdated };
}Key decisions worth noting:
- In-memory caching with a 5-minute TTL avoids unnecessary API calls when the user switches currencies back and forth
useCallbackpreventsfetchRatesfrom being recreated on every render, keeping theuseEffectdependency array stable- Error typing handles both Error instances and unknown throws cleanly
Step 4: Build the CurrencyConverter Component
Now create src/components/CurrencyConverter.tsx:
import { useState, useMemo } from "react";
import { useExchangeRate } from "../hooks/useExchangeRate";
// A curated list of major currencies — expand as needed
const CURRENCIES = [
"USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "CNY",
"INR", "MXN", "BRL", "KRW", "SGD", "HKD", "NOK", "SEK",
"DKK", "NZD", "ZAR", "RUB", "TRY", "AED", "THB", "PLN",
];
export default function CurrencyConverter() {
const [amount, setAmount] = useState<string>("100");
const [baseCurrency, setBaseCurrency] = useState<string>("USD");
const [targetCurrency, setTargetCurrency] = useState<string>("EUR");
const { rates, loading, error, lastUpdated } = useExchangeRate(baseCurrency);
const convertedAmount = useMemo(() => {
if (!rates || !amount) return null;
const rate = rates[targetCurrency];
if (!rate) return null;
return (parseFloat(amount) * rate).toFixed(2);
}, [rates, amount, targetCurrency]);
const currentRate = rates?.[targetCurrency];
const handleSwap = () => {
setBaseCurrency(targetCurrency);
setTargetCurrency(baseCurrency);
};
return (
<div className="converter-card">
<h2>Real-Time Currency Converter</h2>
{/* Amount Input */}
<div className="input-row">
<label htmlFor="amount">Amount</label>
<input
id="amount"
type="number"
min="0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="Enter amount"
/>
</div>
{/* Currency Selectors */}
<div className="currency-row">
<div className="select-group">
<label htmlFor="base">From</label>
<select
id="base"
value={baseCurrency}
onChange={(e) => setBaseCurrency(e.target.value)}
>
{CURRENCIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<button className="swap-btn" onClick={handleSwap} aria-label="Swap currencies">
⇄
</button>
<div className="select-group">
<label htmlFor="target">To</label>
<select
id="target"
value={targetCurrency}
onChange={(e) => setTargetCurrency(e.target.value)}
>
{CURRENCIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
{/* Result */}
<div className="result-area">
{loading && <p className="status-text">Fetching latest rates...</p>}
{error && <p className="error-text">⚠ {error}</p>}
{!loading && !error && convertedAmount !== null && (
<>
<p className="converted-value">
{parseFloat(amount).toLocaleString()} {baseCurrency} =
<strong>{parseFloat(convertedAmount).toLocaleString()} {targetCurrency}</strong>
</p>
{currentRate && (
<p className="rate-info">
1 {baseCurrency} = {currentRate.toFixed(4)} {targetCurrency}
</p>
)}
{lastUpdated && (
<p className="updated-at">
Rates updated: {lastUpdated.toLocaleTimeString()}
</p>
)}
</>
)}
</div>
</div>
);
}This component is intentionally simple and focused. It delegates all API logic to the hook, handles three state variables, and uses useMemo to avoid recalculating the converted amount on every render.
Step 5: Add Environment Variables
Create a .env file in the project root:
VITE_FINEXLY_API_KEY=your_api_key_hereImportant: Add .env to your .gitignore so your API key never ends up in version control:
echo ".env" >> .gitignoreVite exposes env variables prefixed with VITE_ to your client-side code automatically. Never use private or server-side keys in a frontend app — always use public API keys designed for client-side usage.
Step 6: Wire Up the App
Update src/App.tsx to render your new component:
import CurrencyConverter from "./components/CurrencyConverter";
import "./App.css";
function App() {
return (
<main>
<CurrencyConverter />
<footer>
<p>
Exchange rates powered by{" "}
<a href="https://finexly.com" target="_blank" rel="noopener noreferrer">
Finexly
</a>
</p>
</footer>
</main>
);
}
export default App;Run npm run dev and your currency converter should be live, fetching real rates from the Finexly API.
Step 7: Handle Edge Cases
A production-ready converter needs to handle several edge cases:
Invalid amounts: The parseFloat call in convertedAmount will return NaN for empty strings or non-numeric inputs. Guard against this:
const convertedAmount = useMemo(() => {
if (!rates || !amount) return null;
const numAmount = parseFloat(amount);
if (isNaN(numAmount) || numAmount < 0) return null;
const rate = rates[targetCurrency];
if (!rate) return null;
return (numAmount * rate).toFixed(2);
}, [rates, amount, targetCurrency]);Same currency selection: If the user selects the same currency for both fields, the rate should be 1. This is actually handled automatically by the API response (most APIs include the base in its own rates object as 1.0), but worth adding a visual guard:
const isSameCurrency = baseCurrency === targetCurrency;
// In JSX:
{isSameCurrency && <p className="warning">Source and target currencies are the same.</p>}Network failures and offline mode: The error state in the hook already handles API failures. For a more robust solution, consider using a library like React Query which adds retry logic, stale-while-revalidate, and background refetching automatically.
Step 8: Performance Optimization with Debouncing
If you want the conversion to update as the user types, you might want to debounce the amount input to avoid re-triggering expensive operations on every keystroke. Here's a minimal debounce hook:
// src/hooks/useDebounce.ts
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debouncedValue;
}Use it in the component:
const debouncedAmount = useDebounce(amount, 300);
const convertedAmount = useMemo(() => {
// Use debouncedAmount instead of amount
const numAmount = parseFloat(debouncedAmount);
// ...
}, [rates, debouncedAmount, targetCurrency]);In practice, since we're only doing local math (not re-fetching the API on every keystroke), debouncing the amount is optional. The rate fetching is already managed by the useEffect in the hook, which only re-runs when baseCurrency changes.
Step 9: Going Further
Once you have the basic converter working, here are high-value enhancements:
Historical rate chart: Pair the live rate with historical data from the Finexly historical API to show a 30-day rate trend using a library like Recharts or Chart.js.
Multi-currency conversion: Instead of converting to a single currency, show a rate table for the top 10 currencies simultaneously. This is a common pattern in travel and e-commerce apps.
Favorite currencies: Persist the user's preferred currency pairs in localStorage so they don't have to re-select them on every visit.
Server-side rendering with Next.js: If you're building a Next.js app, move the API call to a Route Handler or Server Component so your API key is never exposed to the browser. For a complete guide, see our currency API integration tutorial for Node.js.
Rate alerts: Combine the converter with a notification system to alert users when a currency crosses a target rate. See our guide on building a live forex data feed for trading apps for inspiration.
Frequently Asked Questions
What is the best exchange rate API for React? For React apps, you need a REST API that supports CORS (cross-origin requests from the browser) and provides clear documentation. The Finexly API supports CORS on all plans and provides coverage for 170+ currencies with a free tier. Other options include Open Exchange Rates and ExchangeRate-API.
How often do exchange rates update? The Finexly API updates rates every 60 seconds for real-time plans and once daily for standard plans. For most non-trading applications, daily or hourly rates are sufficient. Always cache responses to avoid hitting rate limits.
Can I use a currency API in a browser (client-side React) app? Yes, if the API supports CORS. The Finexly API allows browser-based requests from any origin on all plans. However, for production apps, it's best practice to proxy API requests through your own backend to protect your API key and add server-side caching.
How do I avoid exposing my API key in React?
For development, use Vite's .env file and prefix your variable with VITE_. For production, route requests through a Next.js API Route, Express server, or serverless function — and keep the key in server-side environment variables only.
How do I convert between two currencies that aren't USD?
Most currency APIs return rates relative to a single base currency. To convert EUR to GBP, you either request EUR as the base directly (if supported), or do the math: result = (amountInEUR / EUR_to_USD_rate) * GBP_to_USD_rate. The Finexly API supports any base currency on all plans, which simplifies this significantly.
Ready to add real-time exchange rates to your React app? Get your free Finexly API key — no credit card required. Start with 1,000 free requests per month and upgrade as you grow.
Explore More
Vlado Grigirov
Senior Currency Markets Analyst & Financial Strategist
Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...
View full profile →