How to Build a Real-Time Currency Converter in React
What We Will Build
In this tutorial, you will build a fully functional currency converter in React that fetches real-time exchange rates from the AllRatesToday API. The finished app will let users select any two currencies from 160+ options, enter an amount, and see the converted value instantly. We will also add a swap button, proper error handling, and a bonus historical rate chart.
AllRatesToday provides mid-market rates sourced from Reuters (Refinitiv) and interbank feeds, updated every 60 seconds. Unlike tutorial APIs that return stale daily data, your converter will show rates that reflect the actual forex market.
Source code: The complete project is available at the end of this tutorial. You can also follow along step by step.
Prerequisites
- Node.js 18+ installed on your machine
- Basic familiarity with React (hooks, state, effects)
- A free AllRatesToday API key — sign up here (no credit card required)
Your API key will look like art_live_.... Keep it handy — you will need it in a moment.
Project Setup with Vite
We will scaffold the project using Vite, which gives us fast hot module replacement and a clean starting point.
npm create vite@latest currency-converter -- --template react
cd currency-converter
npm install Verify the app runs:
npm run dev You should see the default Vite + React page at http://localhost:5173. Now install the AllRatesToday SDK:
npm install @allratestoday/sdk The @allratestoday/sdk package provides a typed client with methods for rates, conversion, symbols, and historical data. No need to write raw fetch calls.
Step 1: API Client Setup
Create a file called src/api.js that initializes the AllRatesToday client. This keeps your API key in one place and makes the client available throughout the app.
// src/api.js
import AllRatesToday from '@allratestoday/sdk';
const client = new AllRatesToday('art_live_YOUR_API_KEY');
export default client; Security note: For a production app, store your API key in an environment variable (VITE_ART_API_KEY) and access it via import.meta.env.VITE_ART_API_KEY. Never commit API keys to version control.
Step 2: Currency Selector Using symbols()
The AllRatesToday SDK includes a symbols() method that returns all supported currencies with their codes and names. We will use this to build a reusable dropdown component.
// src/CurrencySelector.jsx
import { useState, useEffect } from 'react';
import client from './api';
function CurrencySelector({ value, onChange, label }) {
const [currencies, setCurrencies] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
client.symbols()
.then((data) => {
// data.symbols is an object like { USD: "US Dollar", EUR: "Euro", ... }
const list = Object.entries(data.symbols).map(([code, name]) => ({
code,
name,
}));
list.sort((a, b) => a.code.localeCompare(b.code));
setCurrencies(list);
setLoading(false);
})
.catch((err) => {
console.error('Failed to load currencies:', err);
setLoading(false);
});
}, []);
if (loading) return <select disabled><option>Loading...</option></select>;
return (
<div className="selector">
<label>{label}</label>
<select value={value} onChange={(e) => onChange(e.target.value)}>
{currencies.map((c) => (
<option key={c.code} value={c.code}>
{c.code} — {c.name}
</option>
))}
</select>
</div>
);
}
export default CurrencySelector; This component fetches the currency list once on mount, sorts it alphabetically, and renders a <select> element. The value and onChange props let the parent component control which currency is selected.
Step 3: Conversion Using convert()
The SDK's convert() method handles the conversion in a single call. It accepts a source currency, target currency, and amount, then returns the converted result along with the rate used.
// Example usage of convert()
const result = await client.convert('USD', 'EUR', 1000);
// result object:
// {
// source: "USD",
// target: "EUR",
// amount: 1000,
// result: 923.40,
// rate: 0.9234,
// time: "2026-05-25T12:00:00Z"
// } We will wire this into our main converter component next.
Step 4: Amount Input, Display, and Swap Button
Now we build the main Converter component that ties everything together: two currency selectors, an amount input, a swap button, and the conversion result.
// src/Converter.jsx
import { useState, useCallback } from 'react';
import client from './api';
import CurrencySelector from './CurrencySelector';
function Converter() {
const [from, setFrom] = useState('USD');
const [to, setTo] = useState('EUR');
const [amount, setAmount] = useState('1000');
const [result, setResult] = useState(null);
const [rate, setRate] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleConvert = useCallback(async () => {
if (!amount || isNaN(amount) || Number(amount) <= 0) {
setError('Please enter a valid amount greater than zero.');
return;
}
setLoading(true);
setError(null);
try {
const data = await client.convert(from, to, Number(amount));
setResult(data.result);
setRate(data.rate);
} catch (err) {
setError(err.message || 'Conversion failed. Please try again.');
setResult(null);
setRate(null);
} finally {
setLoading(false);
}
}, [from, to, amount]);
const handleSwap = () => {
setFrom(to);
setTo(from);
setResult(null);
setRate(null);
};
return (
<div className="converter">
<h1>Currency Converter</h1>
<div className="converter-row">
<CurrencySelector value={from} onChange={setFrom} label="From" />
<button className="swap-btn" onClick={handleSwap} title="Swap currencies">
⇆
</button>
<CurrencySelector value={to} onChange={setTo} label="To" />
</div>
<div className="amount-row">
<label>Amount</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
min="0"
step="any"
placeholder="Enter amount"
/>
</div>
<button className="convert-btn" onClick={handleConvert} disabled={loading}>
{loading ? 'Converting...' : 'Convert'}
</button>
{error && <div className="error-msg">{error}</div>}
{result !== null && (
<div className="result-box">
<p className="result-amount">
{Number(amount).toLocaleString()} {from} = <strong>{result.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })} {to}</strong>
</p>
<p className="result-rate">
1 {from} = {rate} {to}
</p>
</div>
)}
</div>
);
}
export default Converter; Key details in this component:
- The swap button flips the
fromandtocurrencies and clears the previous result so users know they need to convert again. - The amount input uses
type="number"with validation to prevent non-numeric or negative values. - The result display formats numbers with locale-aware separators and shows both the total and the per-unit rate.
Step 5: Add Styling
Replace the contents of src/App.css with the following styles. This gives the converter a clean, professional look without any external CSS framework.
/* src/App.css */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8fafc;
color: #1a1a1a;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 3rem 1rem;
}
.converter {
background: white;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
padding: 2.5rem;
max-width: 520px;
width: 100%;
}
.converter h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #111827;
text-align: center;
}
.converter-row {
display: flex;
align-items: flex-end;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.selector {
flex: 1;
}
.selector label,
.amount-row label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.4rem;
}
.selector select,
.amount-row input {
width: 100%;
padding: 0.65rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
color: #1f2937;
background: #fff;
}
.swap-btn {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
font-size: 1.25rem;
padding: 0.6rem 0.75rem;
cursor: pointer;
color: #25a557;
transition: background 0.2s;
}
.swap-btn:hover {
background: #dcfce7;
}
.amount-row {
margin-bottom: 1.25rem;
}
.convert-btn {
width: 100%;
padding: 0.75rem;
background: #2ed06e;
color: white;
font-weight: 700;
font-size: 1rem;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.convert-btn:hover {
background: #25a557;
}
.convert-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-msg {
background: #fef2f2;
color: #991b1b;
border-left: 4px solid #ef4444;
padding: 0.75rem 1rem;
border-radius: 0 8px 8px 0;
margin-top: 1rem;
font-size: 0.9rem;
}
.result-box {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 1.25rem;
margin-top: 1.25rem;
text-align: center;
}
.result-amount {
font-size: 1.15rem;
color: #111827;
margin-bottom: 0.25rem;
}
.result-rate {
font-size: 0.85rem;
color: #6b7280;
} Then update src/App.jsx to render the converter:
// src/App.jsx
import './App.css';
import Converter from './Converter';
function App() {
return <Converter />;
}
export default App; At this point, run npm run dev and you should have a working currency converter with live rates from AllRatesToday.
Step 6: Error Handling
The converter component above already includes basic error handling, but let us look at the specific scenarios you should account for in a production app:
- Invalid API key: The SDK throws an authentication error. Display a message prompting the user to check their key.
- Rate limit exceeded: If you exceed your plan's request limit, the API returns a 429 status. Show a "try again later" message and consider caching results.
- Network failure: Wrap API calls in try/catch and display a user-friendly error instead of letting the app crash.
- Invalid input: Validate the amount before calling
convert(). Reject negative numbers, empty strings, and non-numeric input.
Here is a more robust error handler you can use:
function getErrorMessage(err) {
if (err.status === 401) {
return 'Invalid API key. Check your AllRatesToday credentials.';
}
if (err.status === 429) {
return 'Rate limit exceeded. Please wait a moment and try again.';
}
if (err.message?.includes('network') || err.message?.includes('fetch')) {
return 'Network error. Check your internet connection.';
}
return err.message || 'Something went wrong. Please try again.';
} Bonus: Historical Rate Chart Using timeSeries()
The AllRatesToday SDK includes a timeSeries() method that returns historical rates for a currency pair over a date range. Let us add a simple chart below the converter to show the last 30 days of rate history.
First, install a lightweight chart library:
npm install recharts Then create a RateChart component:
// src/RateChart.jsx
import { useState, useEffect } from 'react';
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import client from './api';
function RateChart({ from, to }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const endDate = new Date().toISOString().split('T')[0];
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
client
.timeSeries(from, to, startDate, endDate)
.then((response) => {
// response.rates is an object keyed by date
const chartData = Object.entries(response.rates)
.map(([date, rateInfo]) => ({
date,
rate: rateInfo[to],
}))
.sort((a, b) => a.date.localeCompare(b.date));
setData(chartData);
setLoading(false);
})
.catch((err) => {
console.error('Failed to load historical rates:', err);
setLoading(false);
});
}, [from, to]);
if (loading) return <p style={{ textAlign: 'center', color: '#6b7280' }}>Loading chart...</p>;
if (data.length === 0) return null;
return (
<div className="chart-container">
<h3>{from}/{to} — Last 30 Days</h3>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={data}>
<XAxis
dataKey="date"
tick={{ fontSize: 11 }}
tickFormatter={(d) => d.slice(5)}
/>
<YAxis
domain={['auto', 'auto']}
tick={{ fontSize: 11 }}
tickFormatter={(v) => v.toFixed(4)}
/>
<Tooltip
formatter={(value) => [value.toFixed(4), 'Rate']}
labelFormatter={(label) => label}
/>
<Line
type="monotone"
dataKey="rate"
stroke="#2ed06e"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
export default RateChart; Add the chart below the result in your Converter component:
// Inside Converter.jsx, after the result-box div:
import RateChart from './RateChart';
// Add this after the closing div of result-box:
{result !== null && <RateChart from={from} to={to} />} Add matching styles to App.css:
.chart-container {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.chart-container h3 {
font-size: 0.95rem;
color: #374151;
margin-bottom: 0.75rem;
text-align: center;
} Now after each conversion, users see a 30-day trend chart for the selected currency pair, rendered entirely from AllRatesToday historical data.
Full Code Listing
Here is the complete project with all files. Copy each file into the corresponding path in your Vite project.
src/api.js
import AllRatesToday from '@allratestoday/sdk';
const API_KEY = import.meta.env.VITE_ART_API_KEY || 'art_live_YOUR_API_KEY';
const client = new AllRatesToday(API_KEY);
export default client; src/CurrencySelector.jsx
import { useState, useEffect } from 'react';
import client from './api';
function CurrencySelector({ value, onChange, label }) {
const [currencies, setCurrencies] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
client.symbols()
.then((data) => {
const list = Object.entries(data.symbols).map(([code, name]) => ({
code,
name,
}));
list.sort((a, b) => a.code.localeCompare(b.code));
setCurrencies(list);
setLoading(false);
})
.catch((err) => {
console.error('Failed to load currencies:', err);
setLoading(false);
});
}, []);
if (loading) return <select disabled><option>Loading...</option></select>;
return (
<div className="selector">
<label>{label}</label>
<select value={value} onChange={(e) => onChange(e.target.value)}>
{currencies.map((c) => (
<option key={c.code} value={c.code}>
{c.code} - {c.name}
</option>
))}
</select>
</div>
);
}
export default CurrencySelector; src/Converter.jsx
import { useState, useCallback } from 'react';
import client from './api';
import CurrencySelector from './CurrencySelector';
import RateChart from './RateChart';
function getErrorMessage(err) {
if (err.status === 401) {
return 'Invalid API key. Check your AllRatesToday credentials.';
}
if (err.status === 429) {
return 'Rate limit exceeded. Please wait a moment and try again.';
}
if (err.message?.includes('network') || err.message?.includes('fetch')) {
return 'Network error. Check your internet connection.';
}
return err.message || 'Something went wrong. Please try again.';
}
function Converter() {
const [from, setFrom] = useState('USD');
const [to, setTo] = useState('EUR');
const [amount, setAmount] = useState('1000');
const [result, setResult] = useState(null);
const [rate, setRate] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleConvert = useCallback(async () => {
if (!amount || isNaN(amount) || Number(amount) <= 0) {
setError('Please enter a valid amount greater than zero.');
return;
}
setLoading(true);
setError(null);
try {
const data = await client.convert(from, to, Number(amount));
setResult(data.result);
setRate(data.rate);
} catch (err) {
setError(getErrorMessage(err));
setResult(null);
setRate(null);
} finally {
setLoading(false);
}
}, [from, to, amount]);
const handleSwap = () => {
setFrom(to);
setTo(from);
setResult(null);
setRate(null);
};
return (
<div className="converter">
<h1>Currency Converter</h1>
<div className="converter-row">
<CurrencySelector value={from} onChange={setFrom} label="From" />
<button className="swap-btn" onClick={handleSwap} title="Swap currencies">
⇆
</button>
<CurrencySelector value={to} onChange={setTo} label="To" />
</div>
<div className="amount-row">
<label>Amount</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
min="0"
step="any"
placeholder="Enter amount"
/>
</div>
<button className="convert-btn" onClick={handleConvert} disabled={loading}>
{loading ? 'Converting...' : 'Convert'}
</button>
{error && <div className="error-msg">{error}</div>}
{result !== null && (
<div className="result-box">
<p className="result-amount">
{Number(amount).toLocaleString()} {from} ={' '}
<strong>
{result.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
})}{' '}
{to}
</strong>
</p>
<p className="result-rate">1 {from} = {rate} {to}</p>
</div>
)}
{result !== null && <RateChart from={from} to={to} />}
</div>
);
}
export default Converter; src/RateChart.jsx
import { useState, useEffect } from 'react';
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
} from 'recharts';
import client from './api';
function RateChart({ from, to }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const endDate = new Date().toISOString().split('T')[0];
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
client
.timeSeries(from, to, startDate, endDate)
.then((response) => {
const chartData = Object.entries(response.rates)
.map(([date, rateInfo]) => ({
date,
rate: rateInfo[to],
}))
.sort((a, b) => a.date.localeCompare(b.date));
setData(chartData);
setLoading(false);
})
.catch((err) => {
console.error('Failed to load historical rates:', err);
setLoading(false);
});
}, [from, to]);
if (loading) {
return <p style={{ textAlign: 'center', color: '#6b7280' }}>Loading chart...</p>;
}
if (data.length === 0) return null;
return (
<div className="chart-container">
<h3>{from}/{to} -- Last 30 Days</h3>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={data}>
<XAxis dataKey="date" tick={{ fontSize: 11 }} tickFormatter={(d) => d.slice(5)} />
<YAxis domain={['auto', 'auto']} tick={{ fontSize: 11 }} tickFormatter={(v) => v.toFixed(4)} />
<Tooltip formatter={(value) => [value.toFixed(4), 'Rate']} labelFormatter={(l) => l} />
<Line type="monotone" dataKey="rate" stroke="#2ed06e" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
);
}
export default RateChart; src/App.jsx
import './App.css';
import Converter from './Converter';
function App() {
return <Converter />;
}
export default App; What You Learned
- How to initialize the
@allratestoday/sdkclient in a React project - How to populate currency dropdowns dynamically using
symbols() - How to convert amounts in real time using
convert() - How to fetch and display historical exchange rates using
timeSeries() - How to handle API errors gracefully (auth failures, rate limits, network issues)
- How to build a polished converter UI with vanilla CSS
AllRatesToday gives you real-time mid-market rates for 160+ currencies, sourced from Reuters/Refinitiv and interbank feeds. The free tier is enough to build and test your converter, and you can upgrade as your traffic grows.
Build Your Own Currency Converter Today
Get your free API key in 30 seconds. Real-time rates for 160+ currencies, official SDKs, and no credit card required. See all plans on our Exchange Rate API page.
Get Your Free API Key