DEV Community

Cover image for React i18n - Extend react-intl with your own context and markup
Arnaud
Arnaud

Posted on • Originally published at keypup.io

React i18n - Extend react-intl with your own context and markup

TL;DR; Instead of using the default FormattedMessage component and useIntl hook from react-intl, you should provide your own version of these components with custom variables injected by default. Creating your own wrapper will simplify your code and give more power to your translation keys.

When it comes to internationalization in React the react-intl package is a robust choice and will give you plenty of flexibility in terms of making your translations dynamic, handling pluralization etc.

But like with your code, there are plenty of constants you do not want to hardcode into your translations. This also applies to links and small components you wish to embed inside your translations.

Fortunately it is possible to wrap react-intl inside custom hooks and components to extend it with custom context and markup.

In this article, I will start by showing the basics of using react-intl then show you how to provide your own customized version for better reusability.

Setting up react-intl

Installing and configuring react-intl inside your application is fairly straightforward.

First add the package to your application

# With npm
npm i -S react-intl

# With yarn
yarn add react-intl
Enter fullscreen mode Exit fullscreen mode

Then create a lang folder with an english translation file:

// src/lang/locales/en_US.ts

const messages = {
  'default.welcome': 'Welcome to my app!'
}

export default messages; 
Enter fullscreen mode Exit fullscreen mode

Add a registry with all your available languages:

// src/lang/index.ts

import enMessages from './locales/en_US';

interface LocaleConfig {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    [key: string]: any;
}

const appLocales: LocaleConfig = {
    en: {
      messages: enMessages,
      locale: 'en-US' 
    }
};

export default appLocales;
Enter fullscreen mode Exit fullscreen mode

Finally, configure your top App Component to use react-intl with the chosen language:

// src/index.ts

import React, { FunctionComponent } from 'react';
import { IntlProvider, FormattedMessage } from 'react-intl';
import appLocales from 'lang/index';

const App: FunctionComponent = () => {
  // Get the locale to use. You could use Redux, useContext, URL params or local storage
  // to manage this value.
  const locale = 'en';

  // Load the language configuration
  const localeConfig = appLocales[locale];

  // Application top component (entrypoint)
  return (
    <IntlProvider locale={localeConfig.locale} messages={localeConfig.messages}>
      {/* Add your first translated text */}
      <FormattedMessage id="default.welcome" />
    </IntlProvider>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Alright, we've got react-intl setup. Now let's cover the basics of how to use it.

Using react-intl

In this section we'll see how to use react-intl to translate messages and how to dynamically format these messages.

Component & Hook

There are two ways to use react-intl: components and hooks. They're essentially the same. Let's see both approaches.

Here is how to use react-intl using a component. This is the easiest and most common use of react-intl.

import React, { FunctionComponent } from 'react';
import { FormattedMessage } from 'react-intl';

const MyComponent: FunctionComponent = () => {
  return (
    <div>
      {/* This component will be replaced by the message set under "default.welcome" in your i18n files */}
      <FormattedMessage id="default.welcome" />
    </div>
  );
}

export default MyComponent;
Enter fullscreen mode Exit fullscreen mode

If you need to access messages, you can use the hook version. This is particularly useful when you need to inject translated messages into component props.

import React, { FunctionComponent } from 'react';
import { useIntl, FormattedMessage } from 'react-intl';

const MyComponent: FunctionComponent = () => {
  // Get react-intl service
  const intl = useIntl();

  // Get the formatted button title
  const translatedTitle = intl.formatMessage({ id: 'default.button-title' })

  // Inject your translations
  return (
    <div>
      <button title={translatedTitle}>
        <FormattedMessage id="default.welcome" />
      </button>
    </div>
  );
}

export default MyComponent;
Enter fullscreen mode Exit fullscreen mode

The props of the hook and component versions are the same in the end. Going forward I will use the component version because it is simpler to format. But remember you can always use the hook version if you need.

Message Formatting

Now let's see how to make your messages dynamic. The react-intl library is ICU compliant and has therefore a wide range of dynamic formatting directives.

Formatting always has the following structure in translation keys:

"My cat has {value, type, format} legs"

E.g.
"Almost {pctBlack, number, ::percent} of them are black."
"Coupon expires at {expires, time, short}"
"{gender, select, male {He} female {She} other {They}} will respond shortly."
"You have {itemCount, plural, =0 {no items} one {1 item} other {{itemCount} items}}.
Enter fullscreen mode Exit fullscreen mode

Now this is what these examples look like in React:

import React, { FunctionComponent } from 'react';

const MyComponent: FunctionComponent = () => {
  return (
    <div>
      {/* Injecting variables */}
      {/* mymessage: "I have {catCount} cats and {dogCount} dogs" */}
      <FormattedMessage id="mymessage" values={{ catCount: 3, dogCount: 2 }} />

      {/* Percent formatting */}
      {/* mymessage: "Almost {pctBlack, number, ::percent} of them are black." */}
      <FormattedMessage id="mymessage" values={{ pctBlack: 0.2 }} />

      {/* Date formatting */}
      {/* mymessage: "Coupon expires at {expires, time, short}" */}
      <FormattedMessage id="mymessage" values={{ expires: new Date() }} />

      {/* Select from enum */}
      {/* mymessage: "{gender, select, male {He} female {She} other {They}} will respond shortly." */}
      <FormattedMessage id="mymessage" values={{ gender: 'male' }} />

      {/* Pluralization */}
      {/* mymessage: "You have {itemCount, plural, =0 {no items} one {1 item} other {{itemCount} items}}. */}
      <FormattedMessage id="mymessage" values={{ itemCount: 3 }} />
    </div>
  );
}

export default MyComponent;
Enter fullscreen mode Exit fullscreen mode

You can read more about message syntax on in the FormatJS documentation.

Component injection

You can extend the react-intl markup with custom components. Custom components can be injected in the form of variables or tags.

Here is a concrete example of injecting a break variable and a link tag.

import React, { FunctionComponent } from 'react';

const MyComponent: FunctionComponent = () => {
  return (
    <div>
      {/* Inject a double break and a link to Google */}
      {/* mymessage: "Want to search something?{break2}Go to <link-to-google>Google</link-to-google>" */}
      <FormattedMessage
          id="mymessage"
          values={{
              break2: (
                  <Fragment>
                      <br />
                      <br />
                  </Fragment>
              ),
              'link-to-google': (...chunks: ReactNodeArray) => (
                  <a href="https://www.google.com">{chunks}</a>
              )
          }}
      />
  </div>
  );
}

export default MyComponent;
Enter fullscreen mode Exit fullscreen mode

From there you can inject any custom component into your translation files!

Convenient right? We can do more :)

RichMessage: your custom version of react-intl

As you can see above, it's fairly straightforward to inject custom context into react-intl translation keys.

So what about creating a wrapper around react-intl to make common configuration parameters available in your translation keys without having to explicitly pass values every time. Nothing easier!

Let's start by creating a custom component. This component will inject a list of constant variables into react-intl plus any user-defined variable.

// src/components/RichMessage/RichMessage.tsx

import React, { FunctionComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import { RICH_TAGS } from './RichMessageConfig';

interface Props {
    id: string;
    values?: object;
}

// Wrapper component used to inject common HTML tags in translations
// This allows us to define a kind of "meta language" for translation keys
// with commonly used HTML tags (bold, italic, pre etc.)
export const RichMessage: FunctionComponent<Props> = ({ id, values }: Props) => {
    return <FormattedMessage id={id} values={{ ...RICH_TAGS, ...values }} />;
};
Enter fullscreen mode Exit fullscreen mode

Let's also create a hook version of this component. Note that we only extend useIntl with a formatRichMessage function, we do not override anything. This means that the native react-intl functions are still available in our hook.

// src/components/RichMessage/useRichIntl.tsx

import { useIntl, IntlShape, MessageDescriptor } from 'react-intl';
import { PrimitiveType, FormatXMLElementFn } from 'intl-messageformat';
import { RICH_TAGS } from './RichMessageConfig';

interface RichIntlShape extends IntlShape {
    formatRichMessage(
        descriptor: MessageDescriptor,
        values?: Record<string, PrimitiveType | React.ReactElement | FormatXMLElementFn>
    ): string | React.ReactNodeArray;
}

// Wrapper hook that adds a formatRichMessage. See RichMessage for an explanation.
export const useRichIntl = (): RichIntlShape => {
    const intl = useIntl();

    // Format message with custom HTML tags
    const formatRichMessage = (
        descriptor: MessageDescriptor,
        values?: Record<string, PrimitiveType | React.ReactElement | FormatXMLElementFn>
    ): string | React.ReactNodeArray => {
        return intl.formatMessage(descriptor, { ...RICH_TAGS, ...values });
    };

    return { ...intl, formatRichMessage };
};
Enter fullscreen mode Exit fullscreen mode

Now let's define that missing RICH_TAGS constant. This constant defines all the variables and tags available by default in our translation keys. You will notice that we even created a reusable component for external links.

// src/components/RichMessage/RichMessageConfig.tsx

import React, { ReactNodeArray, Fragment, FunctionComponent } from 'react';

interface ExternalLinkProps {
    href: string;
    children: ReactNodeArray;
}

const ExternalLink: FunctionComponent<ExternalLinkProps> = ({ href, children }: ExternalLinkProps) => {
    return (
        <a href={href} className="hover-underline text-primary" target="_blank" rel="noopener noreferrer">
            {children}
        </a>
    );
};

// Helper method used to generate the link tag function
const externalLinkTag = (href: string): (() => JSX.Element) => {
    return (...chunks: ReactNodeArray): JSX.Element => {
        return <ExternalLink href={href}>{chunks}</ExternalLink>;
    };
};

export const RICH_TAGS = {
    freeTrialDurationDays: 14,
    teamPlanCostUsd: 4.49,
    break: <br />,
    break2: (
        <Fragment>
            <br />
            <br />
        </Fragment>
    ),
    b: (...chunks: ReactNodeArray) => chunks.map((e, i) => <b key={i}>{e}</b>),
    em: (...chunks: ReactNodeArray) => chunks.map((e, i) => <em key={i}>{e}</em>),
    pre: (...chunks: ReactNodeArray) =>
        chunks.map((e, i) => (
            <pre className="d-inline text-secondary" key={i}>
                {e}
            </pre>
        )),
    'text-muted': (...chunks: ReactNodeArray) =>
        chunks.map((e, i) => (
            <span className="text-muted" key={i}>
                {e}
            </span>
        )),
    'text-danger': (...chunks: ReactNodeArray) =>
        chunks.map((e, i) => (
            <span className="text-danger" key={i}>
                {e}
            </span>
        )),
    'link-to-helpcenter-get-started': externalLinkTag(
        'https://help.mysite.com/articles/get-started'
    ),
    'link-to-helpcenter-cancel-account': externalLinkTag(
        'https://help.mysite.com/articles/cancel-account'
    ),
    'link-to-blog': externalLinkTag(
        'https://blog.mysite.com'
    )
};
Enter fullscreen mode Exit fullscreen mode

Finally, let's create a module index to expose our newly created components:

// src/components/RichMessage/index.ts
export * from './RichMessage';
export * from './useRichIntl';
Enter fullscreen mode Exit fullscreen mode

That's it! The constants and tags defined in RICH_TAGS will now always be available in our translation context. We just need to use our new component and hook to benefit from them.

This is the component approach:

import React, { FunctionComponent } from 'react';
import { RichMessage } from 'components/RichMessage';

const MyComponent: FunctionComponent = () => {
  return (
    <div>
      {/* mymessage: "If you need help getting started, read this <link-to-helpcenter-get-started>article</link-to-helpcenter-get-started>." */}
      <RichMessage id="mymessage" />
    </div>
  );
}

export default MyComponent;
Enter fullscreen mode Exit fullscreen mode

This is the hook approach:

import React, { FunctionComponent } from 'react';
import { useRichIntl, RichMessage } from 'components/RichMessage';

const MyComponent: FunctionComponent = () => {
  // Get our custom react-intl service
  const intl = useRichIntl();

  // Get the formatted button title
  // Note that we use the formatRichMessage function this time
  // mymessage: "Remember you benefit from a {freeTrialDurationDays} day free trial"
  const translatedTitle = intl.formatRichMessage({ id: 'mymessage' })

  // Inject your translations
  return (
    <div>
      <button title={translatedTitle}>
        <RichMessage id="default.welcome" />
      </button>
    </div>
  );
}

export default MyComponent;
Enter fullscreen mode Exit fullscreen mode

Easy!

Wrapping up

The react-intl library provides a lot of out-of-the-box functionalities. We strongly recommend reading the FormatJS documentation to get a good grasp on the formatting options it provides.

Once you are comfortable with it, we highly recommend you create a RichMessage/useRichIntl wrapper to expose your constants and extend the FormatJS markup with your own.

Having a custom wrapper will make your translation keys easier to read and your React code simpler by not having to pass important constants as values every time.

Top comments (0)