Guide
Build a React currency converter with live rates
One hook, one component, real formatting, no key in the browser.
What this covers
A currency converter is a good test of an API integration because it exposes every mistake at once: a request per keystroke, a key in the bundle, formatting that ignores the target currency, and a rate with no age attached.
This guide builds the component the right way round: rates fetched once through your own server, conversion as pure arithmetic on the current rate, and the reading timestamp rendered so the figure is never presented as more current than it is.
Expose one endpoint of your own
Your React app should not know the API key or the upstream URL. Add a small route on your server that returns just what the UI needs: the base, the quote map and the reading timestamp.
This also gives you one place to add caching, which is what keeps the balance intact.
server · /api/rates
// Returns { base, rates, asOf } — the key never reaches the browser.
export async function GET() {
const res = await fetch("https://fiatagent.base44.app/api/v1/public/rates");
if (!res.ok) return Response.json({ error: "unavailable" }, { status: 502 });
const { meta, data } = await res.json();
return Response.json(
{ base: meta.base_currency, rates: data.latest.rates, asOf: meta.updated_at },
{ headers: { "cache-control": "public, s-maxage=900" } },
);
}Fetch once in an effect
One effect, one request, no dependency on the amount or the selected currencies — those are pure arithmetic on data you already have. Guard against the double-invocation of React's development mode with an abort controller rather than a flag.
src/useRates.js
import { useEffect, useState } from "react";
export function useRates() {
const [state, setState] = useState({ rates: null, asOf: null, error: null });
useEffect(() => {
const controller = new AbortController();
fetch("/api/rates", { signal: controller.signal })
.then((res) => (res.ok ? res.json() : Promise.reject(new Error("unavailable"))))
.then((payload) => setState({ rates: payload.rates, asOf: payload.asOf, error: null }))
.catch((err) => {
if (err.name !== "AbortError") setState({ rates: null, asOf: null, error: err.message });
});
return () => controller.abort();
}, []);
return state;
}Convert as the user types
Keep the amount as the string the user typed — parsing on every keystroke turns '1.' into 1 and fights the input. Parse once for the calculation and format the result with the target currency's own rules.
src/Converter.jsx
import { useMemo, useState } from "react";
import { useRates } from "./useRates";
export default function Converter() {
const { rates, asOf, error } = useRates();
const [amount, setAmount] = useState("100");
const [from, setFrom] = useState("USD");
const [to, setTo] = useState("EUR");
const converted = useMemo(() => {
const value = Number(amount);
if (!rates || !Number.isFinite(value)) return null;
return (value * rates[to]) / rates[from];
}, [amount, from, to, rates]);
const formatted =
converted === null
? "—"
: new Intl.NumberFormat("en-US", { style: "currency", currency: to }).format(converted);
if (error) return <p role="alert">Rates are unavailable right now.</p>;
return (
<div>
<input value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" />
<select value={from} onChange={(e) => setFrom(e.target.value)}>
{rates && Object.keys(rates).sort().map((code) => <option key={code}>{code}</option>)}
</select>
<select value={to} onChange={(e) => setTo(e.target.value)}>
{rates && Object.keys(rates).sort().map((code) => <option key={code}>{code}</option>)}
</select>
<output>{formatted}</output>
{asOf && <small>Rate read {new Date(asOf).toUTCString()}</small>}
</div>
);
}Keep it honest under server rendering
A converted figure depends on a rate that arrives in the browser, so rendering it during server render guarantees a hydration mismatch. Render the placeholder on the server and the value after the effect resolves — that is what the empty state above does.
If the number must be in the HTML for search or for a screenshot, fetch it in the server render and pass it down as a prop instead of fetching in the browser.
Common questions
Why not call the API directly from the component?
Because a key in a React bundle is public. Even the open endpoint is better called through your server so one cache serves every visitor instead of each browser spending its own daily window.
Should the converted value have a fixed number of decimals?
Use Intl.NumberFormat with the target currency rather than a fixed two decimals — JPY has none, KWD has three, and a hard-coded 2 will be wrong for both.
How do I show a historical rate?
Ask the keyed endpoint for the date range you need with ?from= and ?to=, then pick the reading you want to display. The historical guide covers the shape.
Run it against the real thing.
The open endpoint needs no key, so you can paste these samples into a terminal before you sign up for anything.