Internationalizing and Localizing a React App: i18n Made Easy

joshuaslate

Joshua Slate

Posted on December 1, 2022

Internationalizing and Localizing a React App: i18n Made Easy

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.

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!

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, browser support for Intl.DateTimeFormat 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:

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

<FormatDate day="2-digit" month="2-digit" year="numeric" value={new Date()} />
Enter fullscreen mode Exit fullscreen mode

See the full documentation 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.

Try it!

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

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

<FormatRelativeTime unit="minutes" value={-5}/>
Enter fullscreen mode Exit fullscreen mode

In 10 seconds

<FormatRelativeTime unit="seconds" value={10}/>
Enter fullscreen mode Exit fullscreen mode

See the full documentation 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 has excellent browser support and can deal with currency formatting, percentage formatting, various notations, and more.

Try it!

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

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 for a solution to that.

See the full documentation 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 is useful for language-sensitive string comparison (namely sorting). For example, this factors in diacritics and other characters (e.g., ö, á, ß, ж, ぉ).
  • 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 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 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,
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.

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.

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

yarn add react-i18next i18next i18next-chained-backend i18next-http-backend i18next-localstorage-backend i18next-browser-languagedetector
Enter fullscreen mode Exit fullscreen mode

i18n.ts

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;
Enter fullscreen mode Exit fullscreen mode

public/locales/en-US/common.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."
  }
}
Enter fullscreen mode Exit fullscreen mode

public/locales/es-MX/common.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."
  }
}
Enter fullscreen mode Exit fullscreen mode

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

import './i18n';
Enter fullscreen mode Exit fullscreen mode

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

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 depending on the count. Note that some languages have more complicated pluralization rules than English.
  • <Trans /> 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.

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>
);
Enter fullscreen mode Exit fullscreen mode

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

<Text i18nKey="common:greeting" variables={{name}}/>
Enter fullscreen mode Exit fullscreen mode

Example with interpolation using key from above

<Text i18nKey="common:errors.urgentNotice">
    <strong/>
    <a href="/support"/>
</Text>
Enter fullscreen mode Exit fullscreen mode

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:

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

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

  useEffect(() => {
    document.documentElement.setAttribute('lang', i18n.resolvedLanguage);
  }, [i18n.resolvedLanguage]);
}
Enter fullscreen mode Exit fullscreen mode

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, 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:

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]);
}
Enter fullscreen mode Exit fullscreen mode

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

.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;
}
Enter fullscreen mode Exit fullscreen mode

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:

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

.align-end {
  // text-align: right; Before
  text-align: end; // After
}
Enter fullscreen mode Exit fullscreen mode

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:

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.

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.

💖 💪 🙅 🚩
joshuaslate
Joshua Slate

Posted on December 1, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related