Go to main content
June 30, 2021
Cover image

If you make an application which will be used all over the world, you probably want to handle internationalization for texts, dates and numbers.

It already exists libraries to do that like react-intl, LinguiJS or i18next. In this article we will do our own implementation which is similar to react-intl one.

Before starting to code, it’s important to know React context and understand its use.

Basically, it permits to put some data (object, callback, …) in a Context which will be accessible through a Provider to all children component of this provider. It’s useful to prevent props drilling through many components.

This code:

function App() {
  return (
    <div>
      Gonna pass a prop through components
      <ChildFirstLevel myProp="A prop to pass" />
    </div>
  );
}

function ChildFirstLevel({ myProp }) {
  return <ChildSecondLevel myProp={myProp} />;
}

function ChildSecondLevel({ myProp }) {
  return <ChildThirdLevel myProp={myProp} />;
}

function ChildThirdLevel({ myProp }) {
  // Some process with myProp
  // It's the only component that needs the props

  return <p>This component uses myProp</p>;
}

Can become:

import { createContext, useContext } from 'react';

const MyContext = createContext();

function App() {
  return (
    <MyContext.Provider value="A prop to pass">
      <div>
        Gonna pass a value with react context
        <ChildFirstLevel />
      </div>
    </MyContext.Provider>
  );
}

function ChildFirstLevel() {
  return <ChildSecondLevel />;
}

function ChildSecondLevel() {
  return <ChildThirdLevel />;
}

function ChildThirdLevel() {
  const myProp = useContext(MyContext);
  // Some process with myProp
  // It's the only component that needs the props

  return <p>This component uses myProp</p>;
}

The first step is to create the React context with the Provider which will provides our utilities callback in next parts. This provider will take in parameter the locale which will be used for the current user, which could be the value of navigator.language for example.

import { createContext, useContext, useMemo } from 'react';

const I18nContext = createContext();

const useI18nContext = () => useContext(I18nContext);

function I18nProvider({ children, locale }) {
  const value = useMemo(
    () => ({
      locale,
    }),
    [locale],
  );

  return (
    <I18nContext.Provider value={value}>
      {children}
    </I18nContext.Provider>
  );
}

In the next parts we will add some utilities functions in the context to get our value in function of the locale

Implementation

For our example we will just do an object of translations by locale with locale. Translations will be values by key.

const MESSAGES = {
  en: {
    title: 'This is a title for the application',
    body: 'You need a body content?',
  },
  fr: {
    title: "Ceci est le titre de l'application",
    body: 'Besoin de contenu pour le body?',
  },
};

These translations will be passed to our Provider (but not put in the context).


Now let’s implement the method to get a message from its key in the Provider:

// The messages are passed to the Provider
function I18nProvider({ children, locale, messages }) {
  // The user needs to only pass the messageKey
  const getMessage = useCallback(
    (messageKey) => {
      return messages[locale][messageKey];
    },
    [locale, messages],
  );

  const value = useMemo(
    () => ({
      locale,
      getMessage,
    }),
    [locale, getMessage],
  );

  return (
    <I18nContext.Provider value={value}>
      {children}
    </I18nContext.Provider>
  );
}

It can happen that there is no translation in the current locale (maybe because you do translate messages from a specific enterprise). So it can be useful to give a defaultLocale to fallback to with locale and/or a defaultMessage. The Provider becomes:

// Pass an optional defaultLocale to the Provider
function I18nProvider({
  children,
  locale,
  defaultLocale,
  messages,
}) {
  // Fallback to the `defaultMessage`, if there is no
  // defaultMessage fallback to the `defaultLocale`
  const getMessage = useCallback(
    ({ messageKey, defaultMessage }) => {
      return (
        messages[locale]?.[messageKey] ??
        defaultMessage ??
        messages[defaultLocale][messageKey]
      );
    },
    [locale, messages, defaultLocale],
  );

  const value = useMemo(
    () => ({
      locale,
      getMessage,
    }),
    [locale, getMessage],
  );

  return (
    <I18nContext.Provider value={value}>
      {children}
    </I18nContext.Provider>
  );
}

Get a message value

There is multiple possibilities to get a message:

  • get the function getMessage with useI18nContext
const { getMessage } = useI18nContext();

const title = getMessage({ messageKey: 'title' });
  • implements a component I18nMessage that has messageKey and defaultMessage
function I18nMessage({ messageKey, defaultMessage }) {
  const { getMessage } = useI18nContext();

  return getMessage({ messageKey, defaultMessage });
}

// Use
<I18nMessage messageKey="title" />;
  • implements an HOC withI18n that injects getMessage to our component
function withI18n(WrappedComponent) {
  const Component = (props) => {
    const { getMessage } = useI18nContext();

    return (
      <WrappedComponent
        {...props}
        getMessage={getMessage}
      />
    );
  };
  Component.displayName = 'I18n' + WrappedComponent.name;

  return Component;
}

function Title({ getMessage }) {
  const title = getMessage({ messageKey: 'title' });

  return <h1>title</h1>;
}

const I18nConnectedTitle = withI18n(Title);

Ok, now let’s handle Date formatting. In function of the country (or locale) a date does not have the same displayed format. For example:

// Watch out the month is 0-based
const date = new Date(2021, 5, 23);

// In en-US should be displayed
('6/23/2021');

// In fr-FR should be displayed
('23/06/2021');

// In en-IN should be displayed
('23/6/2021');

To implements this feature, we are gonna use the Intl.DateTimeFormat API which is accessible on all browsers.

Implementations

For the implementation we are gonna expose to the user the possibility to use all the option of the Intl API for more flexibility.

The previous I18nProvider becomes:

function I18nProvider({
  children,
  locale,
  defaultLocale,
  messages,
}) {
  const getMessage = useCallback(
    ({ messageKey, defaultMessage }) => {
      return (
        messages[locale]?.[messageKey] ??
        defaultMessage ??
        messages[defaultLocale][messageKey]
      );
    },
    [locale, messages, defaultLocale],
  );

  const getFormattedDate = useCallback(
    (date, options = {}) =>
      Intl.DateTimeFormat(locale, options).format(date),
    [locale],
  );

  const value = useMemo(
    () => ({
      locale,
      getMessage,
      getFormattedDate,
    }),
    [locale, getMessage, getFormattedDate],
  );

  return (
    <I18nContext.Provider value={value}>
      {children}
    </I18nContext.Provider>
  );
}

If you want to manage numbers, price, … in your project, it can be useful to format these entities in the right one not to disturb users.

For example:

  • separator symbol is not the same
  • the place and the symbol of the currency can be different
const number = 123456.789;

// In en-US should be displayed
('123,456.789');

// In fr-FR should be displayed
('123 456,789');

// In en-IN should be displayed
('1,23,456.789');

To do that we are gonna use the API Intl.NumberFormat which works on all browsers.


If you look at the documentation of Intl.NumberFormat, you can see that there is a tone of options available in second parameter, so in our implementation (like with date formatting) we will pass an options object.

Our I18nProvider becomes then:

function I18nProvider({
  children,
  locale,
  defaultLocale,
  messages,
}) {
  const getMessage = useCallback(
    ({ messageKey, defaultMessage }) => {
      return (
        messages[locale]?.[messageKey] ??
        defaultMessage ??
        messages[defaultLocale][messageKey]
      );
    },
    [locale, messages, defaultLocale],
  );

  const getFormattedDate = useCallback(
    (date, options = {}) =>
      Intl.DateTimeFormat(locale, options).format(date),
    [locale],
  );

  const getFormattedNumber = useCallback(
    (number, options = {}) =>
      Intl.NumberFormat(locale, options).format(number),
    [locale],
  );

  const value = useMemo(
    () => ({
      locale,
      getMessage,
      getFormattedDate,
      getFormattedNumber,
    }),
    [
      locale,
      getMessage,
      getFormattedDate,
      getFormattedNumber,
    ],
  );

  return (
    <I18nContext.Provider value={value}>
      {children}
    </I18nContext.Provider>
  );
}
const getFormattedCurrency = useCallback(
  (number, currency) =>
    Intl.NumberFormat(locale, {
      style: 'currency',
      currency,
    }).format(number),
  [locale],
);

We have seen together how to manage simply manage internationalization in React by using React context. It consists to just pass the locale, message translations to the provider and then put utility methods in the context to get a message translated and formatted date, number or currency.

We also used the wonderful API Intl for formatted date and number which relays on the CLDR.

You can play in live with internationalization here.


You can find me on Twitter if you want to comment this post or just contact me. Feel free to buy me a coffee if you like the content and encourage me.