Internationalizing and Localizing a React App: i18n Made Easy
Tags: react, webdev, javascript, i18n
Published: 2022-12-01 18:02:55

In the beginning of my career as a software engineer, I worked on several niche B2B products that only dealt with end-users who spoke English and were based in the US. In 2018, that all changed when I got the exciting opportunity to pick up my life and move to Barcelona to work for Typeform. Suddenly, not only was I working with a diverse team of other frontend developers from dozens of different countries, but I was building a product that needed to serve users around the world. From there on, I moved to work at an international financial exchange, where we needed to provide financial data, times and dates, and text content in the language and format that our users were accustomed to, with more than 10 languages supported.

In working with such diverse teams on products that needed to support users around the world, I learned a thing or two about making a React application feel like home for users across the globe. In this post, I'd like to share what I've learned.

## What is internationalization?

Simply put, internationalization (sometimes shortened to i18n, where the 18 represents the number of characters between "i" and "n") refers to [developing software in a way that enables localization](https://www.w3.org/International/questions/qa-i18n).

Localization refers to adapting software to meet the language, cultural, and other requirements of a target market. This can include number, date, and time formatting, sorting considerations, translations, and more.

## Dealing with Dates and Times

It's hard to overstate the importance of localizing dates and times. To a person from the US, 01/02/2022 means January 2nd, 2022. To someone from Australia, that would mean February 1st, 2022. In addition to differences in terms of layout, there are
differences in separators, different words, and different clock cycles that are used in different locales.

How could this become problematic? An example: a US-based company's web app tells global users that an important, action-required step needs to be taken by 01/02/2022. From a quick glance, a European or Australian user reads this as February 1st (instead of the intended January 2nd) and simply forgets about it until receiving notice in their email on January 3rd that they failed to perform the required steps by the deadline.

### Try it!
{% embed https://codesandbox.io/embed/react-i18n-guide-date-hyy20t?fontsize=14&hidenavigation=1&theme=dark %}

Localizing dates used to be painful, and could involve generating different bundles for different locales, as the locale data was quite heavy in libraries like Moment, for example. Fortunately, according to [CanIUse](https://caniuse.com/mdn-javascript_builtins_intl_datetimeformat_format), browser support for [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format) is greater than 97% at the time of writing. This means that most applications should be able to leverage the browser's
date localization capabilities without needing to depend on heavy third-party dependencies.

The best way I've found to handle locale-aware date and time formatting is to create a simple component that leverages `Intl.DateTimeFormat`:

```tsx
import React, { BaseHTMLAttributes} from 'react';

interface Props extends BaseHTMLAttributes<HTMLTimeElement>, Intl.DateTimeFormatOptions {
  fractionalSecondDigits?: 1 | 2 | 3;
  value?: string | number | Date;
  locale?: string;
}

const getDateString = (value: Props['value']): string | undefined => {
  if (!value) {
    return undefined;
  }

  try {
    const valueAsDate = new Date(value);

    return valueAsDate.toISOString();
  } catch {
    return undefined;
  }
};

const safelyFormatValue = (formatter: Intl.DateTimeFormat, value: Props['value']): string => {
  if (!value) {
    return '';
  }

  try {
    const dateFromValue = new Date(value);
    return formatter.format(dateFromValue);
  } catch {
    return '';
  }
};

export const FormatDate: React.FC<Props> = ({
  fractionalSecondDigits,
  hour,
  minute,
  day,
  month,
  year,
  second,
  value,
  timeZoneName,
  locale = 'default',
  ...rest
}) => {
  const dateFormatter = new Intl.DateTimeFormat(locale, {
    fractionalSecondDigits,
    hour,
    minute,
    day,
    month,
    year,
    second,
    timeZoneName,
  });

  return (
    // The dateTime attribute shows the ISO date being formatted with a quick DOM inspection
    <time dateTime={getDateString(value)} {...rest}>
      {safelyFormatValue(dateFormatter, value)}
    </time>
  );
};
```

The component code above was used for the date you see in the "Try it" section above. It is used like so:

```tsx
<FormatDate day="2-digit" month="2-digit" year="numeric" value={new Date()} />
```

See the [full documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat) for more available options.

### Relative Time
Many applications leverage relative times, for example, `User commented 3 minutes ago` or `Livestream starting in 42 seconds`. Fortunately for us, the browser offers a solution for this as well: [Intl.RelativeTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat).

### Try it!
{% embed https://codesandbox.io/embed/react-i18n-guide-relative-time-g4btvf?fontsize=14&hidenavigation=1&theme=dark %}

As with `Intl.DateTimeFormat`, I've found it easiest to create a simple component to leverage `Intl.RelativeTimeFormat`:

```tsx
import React, { BaseHTMLAttributes} from 'react';

/**
* style is omitted from Intl.RelativeTimeFormatOptions and renamed to formatStyle to prevent a clash with the
* BaseHTMLAttributes "style" (which would be the CSS style object)
*/
interface Props extends BaseHTMLAttributes<HTMLSpanElement>, Omit<Intl.RelativeTimeFormatOptions, 'style'> {
  formatStyle?: Intl.RelativeTimeFormatStyle;
  unit: Intl.RelativeTimeFormatUnit;
  value: number;
  locale?: string;
}

export const FormatRelativeTime: React.FC<Props> = ({ formatStyle, locale = 'default', numeric, unit, value, ...rest }) => {
  const relativeTimeFormatter = new Intl.RelativeTimeFormat(locale, { style: formatStyle, numeric });

  return (
    // The unit and value data attributes make debugging easier with a quick DOM inspection
    <span data-unit={unit} data-value={value} {...rest}>
      {relativeTimeFormatter.format(value, unit)}
    </span>
  );
};
```

The component code above was used for the date you see in the "Try it" section above. It is used like so:

**5 minutes ago**
```tsx
<FormatRelativeTime unit="minutes" value={-5}/>
```

**In 10 seconds**
```tsx
<FormatRelativeTime unit="seconds" value={10}/>
```

See the [full documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat) for more available options.

## Dealing with Numbers
Just as with dates, different locales handle number and currency formatting differently. For example, in the US, we use the comma
as the thousands separator (1,024) and the period as the decimal separator (3.14). In much of Europe, the period is used as the thousands separator (1.024) and the comma is used as the decimal separator (3,14). [Intl.NumberFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) has excellent browser support and can deal with currency formatting, percentage formatting, various notations, and more.

### Try it!

{%embed https://codesandbox.io/embed/react-i18n-guide-number-lyqte0?fontsize=14&hidenavigation=1&theme=dark %}

As with date and time formatting, I've found it easiest to create a simple component to leverage `Intl.NumberFormat`:

```tsx
import React, { BaseHTMLAttributes } from 'react';

/**
* style is omitted from Intl.NumberFormatOptions and renamed to formatStyle to prevent a clash with the
* BaseHTMLAttributes "style" (which would be the CSS style object)
*/
interface Props extends BaseHTMLAttributes<HTMLSpanElement>, Omit<Intl.NumberFormatOptions, 'style'> {
  formatStyle?: string;
  locale?: string;
  value?: number;
}

export const FormatNumber: React.FC<Props> = ({
  currency,
  currencySign,
  useGrouping,
  minimumIntegerDigits,
  minimumFractionDigits,
  maximumFractionDigits,
  minimumSignificantDigits,
  maximumSignificantDigits,
  formatStyle,
  locale,
  value,
  ...rest
}) => {
  const numberFormatter = new Intl.NumberFormat(locale, {
    style: formatStyle,
    currency,
    currencySign,
    useGrouping,
    minimumIntegerDigits,
    minimumFractionDigits,
    maximumFractionDigits,
    minimumSignificantDigits,
    maximumSignificantDigits,
  });

  return (
    // The number data attribute makes debugging easier with a quick DOM inspection
    <span data-number={value?.toString()} {...rest}>
      {value === undefined || Number.isNaN(value) ? '' : numberFormatter.format(value)}
    </span>
  );
};
```

Note: if your application needs to support formatted cryptocurrency prices or values, Bitcoin is actually supported by the `Intl.NumberFormat` API with the currency code `BTC`, but many lesser known coin projects are not. Check out my post on [working with currency values in TypeScript](https://dev.to/joshuaslate/working-with-currency-values-in-typescript-1b3d) for a solution to that.

See the [full documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat) for more available options.

## More from Intl

The `Intl` object has been under development in recent years, and there are several additional features worth exploring:

- [Intl.Collator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator) is useful for language-sensitive string comparison (namely sorting). For example, this factors in diacritics and other characters (e.g., ö, á, ß, ж, ぉ).
- [Intl.ListFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat)
is useful for formatting groups of items into a string format. For example, German and several other languages do not use the Oxford comma, while English does.
- [Intl.PluralRules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules) is useful for pluralizing items, as pluralization rules differ between locales. It can also be used for ordinal values (e.g., 1st, 3rd, 5th).
- [Intl.Segmenter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter) is
useful for splitting a string in a locale-sensitive manner. Did you know that there are languages (like Japanese, Chinese, and Thai) that don't use whitespace between words? This means that `String.prototype.split(' ')` will not actually provide an array of words in the sentence in these languages. Browser support for `Intl.Segmenter` is ok, but is notably missing Firefox support at the time of writing.

Note that some of this functionality can also be found in the i18n library I recommend in the next section.

## Translation

As of 2022, [there are 1.453 billion people in the world who speak English](https://www.ethnologue.com/guides/most-spoken-languages),
of which, only about 26% are native English speakers. With a global population of ~8 billion, that means only ~18% of the global population speaks English. There's a clear business case here for many applications to support additional languages.

Next, I'm going to break down how to serve and manage translations.

### Serving Translations

While I believe there is a lot of valid criticism for using a library for everything in the JavaScript ecosystem, I strongly believe this is a case where you want to put your trust in the experts of the open-source community. The most common library I've seen for serving translations in a React app is [react-i18next](https://react.i18next.com/).

#### Initialize i18next
First, decide how you want to serve your translations. They can be bundled into your app's entrypoint bundle (probably not recommended), loaded as JSON via HTTP request from your `public` directory, or loaded via a third-party service. To get started, I would recommend serving from your `public` directory via the HTTP backend, but you can find a list of available backends [here](https://www.i18next.com/overview/plugins-and-utils#backends).

Next, install the dependencies (substituting in your preferred backend as applicable):

```shell
yarn add react-i18next i18next i18next-chained-backend i18next-http-backend i18next-localstorage-backend i18next-browser-languagedetector
```

**i18n.ts**
```tsx
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-chained-backend';
import LocalStorageBackend from 'i18next-localstorage-backend';
import HTTPBackend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';

const backends = process.env.ENVIRONMENT === 'development'
    // in dev, always pull the latest translations, as they could be updating constantly
    ? [HTTPBackend]
    // in production, attempt to get locale storage-cached translations before hitting HTTP backend
    : [LocalStorageBackend, HTTPBackend];

i18n.use(Backend).use(LanguageDetector).use(initReactI18next).init({
    backend: {
      backends,
      backendOptions: [
        {
          prefix: '@@i18n_',
          expirationTime: 7 * 24 * 60 * 60 * 1000 * 7, // 1 week by default,
          store: window.sessionStorage,
          defaultVersion: process.env.CI_COMMIT_SHA, // This could be your app version
        },
        {
          loadPath: '/locales/{{lng}}/{{ns}}.json',
        },
      ],
    },
    supportedLngs: ['en-US', 'es-MX'],
    load: 'all',
    fallbackLng: 'en-US',
    ns: ['common'],
    defaultNS: 'common',
    interpolation: {
      escapeValue: false, // not needed for react, as it escapes by default
    },
});

export default i18n;
```

**public/locales/en-US/common.json**
```json
{
  "greeting": "Hello {{name}}",
  "itemsToReview_one": "You have {{count}} item to review.",
  "itemsToReview_other": "You have {{count}} items to review.",
  "errors": {
    "userNotFound": "User not found",
    "urgentNotice": "<0>URGENT!</0> There was a problem. Click <1>here</1> for help."
  }
}
```

**public/locales/es-MX/common.json**
```json
{
  "greeting": "Hola {{name}}",
  "itemsToReview_one": "Tienes {{count}} cosa que revisar.",
  "itemsToReview_other": "Tienes {{count}} cosas que revisar.",
  "errors": {
    "userNotFound": "Usuario no encontrado",
    "urgentNotice": "<0>¡Urgente!</0> Había un problema. Haga clic <1>aquí</1> para obtener ayuda."
  }
}
```

The next step is to import your `i18n` instance into your app's entry point (`index.tsx` or the like):

```tsx
import './i18n';
```

Once this is done, you can start using the `useTranslation()` hook to replace hard-coded strings with translated ones:

```tsx
import { useTranslation, Trans } from 'react-i18next';

interface Props {
  error: any;
  itemsToReview: number;
  name: string | undefined;
}

export const Greeting: React.FC<Props> = ({ error, itemsToReview, name }) => {
  const { t } = useTranslation('common');

  if (error) {
    return (
      <div>
        <Trans t={t} i18nKey="common:errors.urgentNotice">
          <strong />
          <a href="/support" />
        </Trans>

        <p>{t('common:errors.userNotFound')}</p>
      </div>
    );
  }

  return (
    <div>
      <span>{t('common:greeting', { name })}</span>
      <span>{t('common:itemsToReview', { count: itemsToReview })}</span>
    </div>
  );
};
```

In the example above, you can see examples of a few of the key `react-i18next` concepts:

- `common`, passed to `useTranslation()` and prepended in the `i18nKey` is the namespace. In order for i18next to load a namespace from your configured backend, you need to pass it to `useTranslation()`, which can take an array of namespaces. Keys are always prefixed with their namespace, which by default matches the filename before `.json` of the file that the translation is located in.
- `t`, returned by `useTranslation()` is the function that handles translating simple strings that don't require interpolation. The first argument is the `i18nKey` and the second is the variable object required for the key. You can also see an example of a key, `common:itemsToReview`, which has different [pluralization rules](https://www.i18next.com/translation-function/plurals) depending on the count. Note that some languages have more complicated pluralization rules than English.
- [`<Trans />`](https://react.i18next.com/latest/trans-component) is used to interpolate more complex items into your translations (like React Elements). In this case, it's used to translate the support link as part of a full phrase.

I like to use an abstraction component over `react-i18next` to help with debugging translation issues with peers across the business.

```tsx
import React, { BaseHTMLAttributes, Suspense } from 'react';
import { Trans, useTranslation } from 'react-i18next';

interface Props extends BaseHTMLAttributes<HTMLSpanElement> {
  element?:
    | 'span'
    | 'b'
    | 'strong'
    | 'em'
    | 'p'
    | 'h1'
    | 'h2'
    | 'h3'
    | 'h4'
    | 'h5'
    | 'h6';
  i18nKey: string;
  variables?: Record<string, any>;
}

const useTranslationProps = ({
  children,
  i18nKey,
  variables,
  ...rest
}: Omit<Props, 'element'>) => {
  const namespace = i18nKey.split(':')[0];
  const { t } = useTranslation(namespace);

  return {
    ...rest,
    'data-i18n-key': i18nKey,
    'children': children ? (
      <Trans t={t} i18nKey={i18nKey} values={variables}>
        {children}
      </Trans>
    ) : (
      t(i18nKey, variables)
    ),
  };
};

const TextInner: React.FC<Props> = ({ element = 'span', ...rest }) => {
  const props = useTranslationProps(rest);

  switch (element) {
    case 'b':
      return <b {...props} />;
    case 'strong':
      return <strong {...props} />;
    case 'em':
      return <em {...props} />;
    case 'p':
      return <p {...props} />;
    case 'h1':
      return <h1 {...props} />;
    case 'h2':
      return <h2 {...props} />;
    case 'h3':
      return <h3 {...props} />;
    case 'h4':
      return <h4 {...props} />;
    case 'h5':
      return <h5 {...props} />;
    case 'h6':
      return <h6 {...props} />;
    case 'span':
    default:
      return <span {...props} />;
  }
};

export const Text: React.FC<Props> = (props) => (
  // I would recommend having a higher-level Suspense with a loading spinner fallback
  <Suspense fallback="[...]">
    <TextInner {...props} />
  </Suspense>
);
```

What this accomplishes is:

- It gives you a consistent interface to add translated text to your interface, regardless of interpolation.
- It adds the `data-i18n-key` data attribute to your translated text in the DOM, so your peers across the business can quickly point you to the translation key that is causing trouble, regardless of which language they're using the application in.
- It ensures that the required namespaces are loaded into your application.

**Simple example using key from above**
```tsx
<Text i18nKey="common:greeting" variables={{name}}/>
```

**Example with interpolation using key from above**
```tsx
<Text i18nKey="common:errors.urgentNotice">
    <strong/>
    <a href="/support"/>
</Text>
```

#### Update the `html` Tag with the Appropriate `lang` Attribute

It's important to update your application's `html` tag with the appropriate `lang` attribute, because it gives context to screen readers and other assistive technologies. Here's a sample hook that does just that:

```tsx
import { useEffect } from 'react';
import { useTranslation } from 'react-18next';

export const useUpdateHTMLLanguage = () => {
  const { i18n } = useTranslation();

  useEffect(() => {
    document.documentElement.setAttribute('lang', i18n.resolvedLanguage);
  }, [i18n.resolvedLanguage]);
}
```

### Translation Management

While actually having the translations written in various locales is not likely in scope for an engineering team, I'll share what I've seen work.

The engineering team should commit to adding new translation keys for one locale. In this case, let's assume `en-US` for the sake of this example. As the engineers are working, they will add copy to the `locales/en-US.json` file and carry on with their work. When they open a pull request for their work upstream to their `development` environment, a script can run in CI that will check for newly-created keys that aren't present in all the supported locales and notify the appropriate translators that your branch needs translation work. Alternatively, you could consider a tool like [translation-check](https://github.com/locize/translation-check), which will create a simple dashboard where you can get an overview of missing translations by locale.

There are also a number of third-party services that offer translation management and collaboration dashboards (Locize, Loco, Lokalise, etc.).

### Layout Shifting

It's worth noting that many times, layouts are only designed with the default language in mind, which is oftentimes English. There are a number of languages that _can be_ more verbose than English. For example, Spanish, Portuguese, and Russian are often more space-consuming than English and can cause overflow issues for your application. Unfortunately, there's not a great way to prevent this. I would recommend naming your translation keys in a manner that is self-explanatory to the translators to ensure they have space constraints in-mind when crafting their translations.

I haven't found a great automated tool for testing overflow, but I would recommend manually testing translation additions in all languages as part of your pre-release process. Of course, the most important areas to focus on will depend on your application, but I have often seen issues with navigation overflow in both headers and footers. The layout elements are the lowest-hanging fruits.

## Right-to-Left (RTL)

While the majority of languages are written from left-to-right (LTR), there are a few prominent languages that are written from right-to-left (e.g., Arabic and Hebrew). I have not yet had to work on an application that had business requirements to support RTL, but from what I understand, there are a few key concepts to keep in mind.

### Update the `html` Tag with the Appropriate `dir` Attribute

The `dir` attribute in the page's `html` tag should be set to `rtl` for right-to-left languages, otherwise `ltr`, `auto`, or unset.

Here's a sample hook that does just that:

```tsx
import { useEffect } from 'react';
import { useTranslation } from 'react-18next';

export const useUpdateHTMLDirection = () => {
  const { i18n } = useTranslation();
  const dir = i18n.dir(); // Where dir will be 'ltr' or 'rtl';

  useEffect(() => {
    document.documentElement.setAttribute('dir', dir);
  }, [dir]);
}
```

Note that this could be combined with the hook to update the language on the `html` tag as well.

You can also use the [dir](https://www.w3.org/International/questions/qa-html-dir) attribute on structural elements throughout your page if only parts of your application need to read from right-to-left.

### Flip the Layout

The ["F" layout](https://uxplanet.org/f-shaped-pattern-for-reading-content-80af79cd3394) that many websites use is great for languages that read from left-to-right, but it should be flipped for your users that are reading your website from right-to-left. This can be accomplished without much effort if you use flexbox or grid-based layouts. For example:

```scss
.flex-layout {
  display: flex;
  flex-direction: row;
}

.grid-layout {
  display: grid;
  grid-template-columns: 320px 1fr;
}

[dir="rtl"] .flex-layout {
  flex-direction: row-reverse;
}

[dir="rtl"] .grid-layout {
  grid-template-columns: 1fr 320px;
}
```

### Realign the Text

For right-to-left languages, you will likely want to flip the text alignment you have in your standard left-to-right template to better match the flipped layout. You can accomplish this fairly easily by making the following replacements:

```scss
.align-start {
  // text-align: left; Before
  text-align: start; // After
}

.align-end {
  // text-align: right; Before
  text-align: end; // After
}
```

### Fix Spacing

Instead of using hard `*-left` and `*-right` spacing values (margin and padding), you will want to use the following
replacements that take writing directionality in mind:
- [padding-inline-start](https://developer.mozilla.org/en-US/docs/Web/CSS/padding-inline-start) in place of padding-left
- [padding-inline-end](https://developer.mozilla.org/en-US/docs/Web/CSS/padding-inline-end) in place of padding-right
- [margin-inline-start](https://developer.mozilla.org/en-US/docs/Web/CSS/margin-inline-start) in place of margin-left
- [margin-inline-end](https://developer.mozilla.org/en-US/docs/Web/CSS/margin-inline-end) in place of margin-right

### Further Reading: Vertical Writing

Some languages are traditionally written vertically. I haven't worked on an application that supports vertical writing yet, but I wanted to call out that this is yet another case that might need to be accounted for, and that there is support for this in CSS via [writing-mode](https://www.w3.org/International/articles/vertical-text/index).

## Conclusion

While the diversity in culture, tradition, and language are some of the things that make the world so beautiful, they can cause unsuspecting engineers a lot of trouble. It would be unrealistic to expect any single engineer, or even team, to hold all the information required to internationalize an application manually. Fortunately, it has never been easier to internationalize and localize an application than it is today with `Intl` reaching great browser support and developing rapidly. If you think there's something I missed or got wrong, reach out to me on Twitter and share [@joshuaslate](https://twitter.com/joshuaslate).