Jacob Paris
← Back to all content

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

tsx
<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

ts
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.

ts
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.

ts
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.

tsx
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.

tsx
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.

Professional headshot
Moulton
Moulton

Hey there! I'm a developer, designer, and digital nomad building cool things with Remix, and I'm also writing Moulton, the Remix Community Newsletter

About once per month, I send an email with:

  • New guides and tutorials
  • Upcoming talks, meetups, and events
  • Cool new libraries and packages
  • What's new in the latest versions of Remix

Stay up to date with everything in the Remix community by entering your email below.

Unsubscribe at any time.