Currency handling in React
This article will not help you build banking software or set up a stock exchange. But there are millions of businesses that run on spreadsheets and this will help you build the kind of software that replaces them.
When working with currencies in JavaScript, you can get around the technical limitations of the language with two rules
- store currency in integer cents
- store percents as strings
Not every currency has "cents", but every currency has a "smallest unit", and that's what you should store in the database. By avoiding floating point numbers, you avoid the entire category of problems that arise from using them.
Some things MUST be decimal values, such as exchange rates, interest rates, or tax rates, so store a string decimal value.
If you choose to use a fancy math library like decimal.js or BigNumber, and use specialized columns in your database that can store high precision values, remember that the inputs in the browser are text and JSON over the network is text and allowing the application to work with simple primitives will remove many headaches.
Make a Currency component
The three most common ways I display currencies are as follows
- dollar value in the base currency
- dollar value in a different currency
- converted value in whichever currency
- negative value in parentheses
The API of this currency component covers those nicely
<Currency cents={100} /> // $1.00<Currency cents={100} from="EUR" /> // €1.00<Currency cents={100} from="USD" to="EUR" /> // €1.23<Currency cents={-200} /> // ($2.00)export function Currency({ cents, className, from, to, ...props}: { cents: number from?: string to?: string} & Omit<React.ComponentProps<"span">, "children">) { const { getExchangeRate, baseCurrency } = useCurrencies() const isNegative = cents < 0 const fromCurrency = from || baseCurrency.name const toCurrency = to || fromCurrency const exchangeRate = getExchangeRate(fromCurrency, toCurrency) let convertedAmount = cents * exchangeRate const displayAmount = formatCurrency(convertedAmount, { currencyFrom: { name: fromCurrency }, currencyTo: { name: toCurrency }, locale: baseCurrency.locale, }) return ( <span className={cn("tabular-nums", isNegative && "text-red-600", className)} {...props} > {displayAmount} </span> )}
To implement this, you'll need
- a
formatCurrency
function - a
useCurrencies
hook to provide the exchange rates and currency info
Use built-in locale formatting
Browsers have built-in functionality for formatting currencies, and it looks like this
const formatted = convertedCents.toLocaleString("en-US", { currency: "CAD", style: "currency",})
The locale is very important, especially if you're working with multiple currencies that use dollar signs. One hundred US dollars is $100.00
in en-US
, but US$100.00
in en-CA
. Similarly, one hundred Canadian dollars is $100.00
in en-CA
, but CA$100.00
in en-US
.
Choose a base currency for your document or application, and use the locale of that currency to format all the numbers. It's ok if this is something the user can change manually, but it should be consistent to avoid confusion.
export function parseNumber(value: string): number { // This can be improved // Users will type whatever they want, // and we should accept that return parseFloat(value.replace(/[^0-9.-]+/g, ""))}export function formatCurrency( cents: number | string | undefined | null, { currencyFrom = { name: "USD" }, currencyTo, locale, }: { currencyFrom?: { name: string } currencyTo?: { name: string } locale?: string } = {},) { if (cents == undefined) { return "" } if (!currencyTo) { currencyTo = currencyFrom } if (typeof cents === "string") { cents = parseNumber(cents) } // Some currencies don't have a cents * 100 === dollars relationship // If you're working with those, you'll need to track this per-currency const convertedCents = cents / 100 const formatted = convertedCents.toLocaleString(locale, { currency: currencyTo.name, style: "currency", }) // Parentheses around negatives is a common accounting convention // But you can do whatever you want here return formatted.startsWith("-") ? `(${formatted.slice(1)})` : `${formatted}`}
Provide currency info with React Context
Currencies are a little bit of global state here, and a context provider allows you to consume them with a useCurrencies
hook.
Each currency needs to store its currency code, its locale, and an exchange rate (relative to something common like USD). Then you can derive the exchange rate between any two currencies by dividing them.
const [baseCurrencyName, setBaseCurrencyName] = useState("USD")const currencies = [ { name: "USD", locale: "en-US", rate: "1" }, { name: "EUR", locale: "de-DE", rate: "0.91" }, { name: "GBP", locale: "en-GB", rate: "0.79" }, { name: "CAD", locale: "en-CA", rate: "1.34" },]const baseCurrency = currencies.find((c) => c.name === baseCurrencyName) || currencies[0]const getExchangeRate = (from: string, to: string) => { const fromCurrency = currencies.find((c) => c.name === from) const toCurrency = currencies.find((c) => c.name === to) if (!fromCurrency || !toCurrency) return 1 return toCurrency.rate / fromCurrency.rate}return ( <CurrenciesProvider currencies={currencies} baseCurrency={baseCurrency}> <App /> </CurrenciesProvider>)
The context provider just passes the data through.
const CurrenciesContext = createContext<{ currencies: Currency[] baseCurrency: Currency getExchangeRate: (from: string, to: string) => number}>({ currencies: [], baseCurrency: { name: "USD", locale: "en-US", rate: "1" }, getExchangeRate: () => 1,})export function useCurrencies() { const context = use(CurrenciesContext) if (context === undefined) { throw new Error("useCurrencies must be used within a CurrenciesProvider") } return context}export function CurrenciesProvider({ children, currencies, baseCurrency, getExchangeRate,}: { children: React.ReactNode currencies: Currency[] baseCurrency: Currency getExchangeRate: (from: string, to: string) => number}) { return ( <CurrenciesContext.Provider value={{ currencies, baseCurrency, getExchangeRate, }} > {children} </CurrenciesContext.Provider> )}
Format inputs on blur but don't mask while typing
An "input mask" formats the value of an input while the user is typing, such as adding commas to a number ,or adding a dollar sign and decimals to a currency, or brackets to a phone number.
These are always a janky experience. If a user tries to delete part of the mask and deletes the value instead, or types their own punctuation and it either doubles up or appears to do nothing,
A popular compromise is to store a "display" and an "edit" value, and show the unformatted value while focused. This is also bad because when a user clicks into the input between two characters, they expect their caret to be between those two characters, and it's very hard to make that work with this approach.
Input masking is still an unsolved problem. Formatting on blur is a VERY solved problem.
🔑 Allow the user to type whatever they want, then parse and format it when they leave the input.
- you can autosave at this time
- you can show errors at this time
- when the user wants to make a change, they can deal with the formatted value
This implementation will vary wildly depending on your form state management strategy and your input component.
export function CurrencyInput({ currency, defaultValue, ...props}: { currency: string} & React.ComponentProps<typeof Input>) { const { baseCurrency } = useCurrencies() const inputRef = useRef<HTMLInputElement>(null) const transform = (value: string) => formatCurrency(parseNumber(value) * 100, { currencyFrom: { name: currency }, locale: baseCurrency.locale, }) useEffect(() => { if (inputRef.current) { inputRef.current.value = transform(inputRef.current.value) } }, [currency, baseCurrency.locale]) return ( <Input type="text" ref={inputRef} onBlur={() => { const newValue = transform(inputRef.current!.value) inputRef.current!.value = newValue props.onBlur?.(newValue) }} {...props} /> )}
Example
Here is an interactive example of the currency component on v0.dev.