Software localization
A Guide to Localizing React Apps with react-intl/FormatJS
At 20M NPM downloads per week and integrations in everything from WordPress to native mobile apps, it’s safe to say that React has become the de facto UI library for the web and beyond.
And when it comes to React app internationalization (i18n) and localization (l10n), the react-intl library is hard to beat. react-intl sits atop the FormatJS i18n libraries, themselves standards-focused and used by giants like Dropbox and Mozilla. react-intl also boasts over a million NPM downloads per week, so it’s beyond battle-tested.
In this hands-on guide, we’ll show you how to localize a React app step by step using the react-intl library. We’ll begin with the fundamentals of internationalization, then move on to working with translations, as well as handling date and number formatting.
As we progress, we’ll delve into more advanced topics such as message extraction and integrating a dedicated software localization platform like Phrase Strings. This integration will help us automate the localization process on a larger scale.
Phrase Strings
Take your web or mobile app global without any hassle
Adapt your software, website, or video game for global audiences with the leanest and most realiable software localization platform.
🔗 Resource » If you want a comparison of React i18n libraries, check out our best-off list of React libraries for internationalization.
What are internationalization (i18n) and localization (l10n)?
In practice, internationalization and localization often tend to be used interchangeably, but they don’t mean the same thing. Internationalization (i18n) is the process of making an app ready for localization: not hard-coding UI strings and using keyed translation instead, for example. Localization (l10n) is making an app useable by people of different cultures and languages—this often means the actual translation, using correct date formats, etc.
What is a locale?
A locale is a geographical region with a specific language. We represent a locale using a code: fr-CA
means French from Canada, while fr-FR
means French from France. We often interchange the terms language and locale, but it’s important to know the difference*.*
🔗 Resource » Here’s a handy list of locale codes.
Alright, let’s get practical.
Our demo app
We’ll work with a small app that we’ll localize step by step. Here it is before any i18n or l10n:
📣 Shoutout » The burger image was created using Midjourney. Our app icon was created by Kawalan Icon from the Noun Project.
Package versions used
We used the following NPM packages when developing this app.
Library | Version used | Description |
react | 18.2.0 | Our main UI library |
react-intl | 6.4.4 | Used to localize our app |
@formatjs/intl-localematcher | 0.4.0 | Helps detect the browser locale |
tailwindcss | 3.3.2 | For styling; optional for our purposes. |
Let’s get to building. We’ll spin up our app from the command line using Create React App.
npx create-react-app i18n-demo
Code language: Bash (bash)
Our React components are presentational; the root <App>
houses a <Header>
and a <Recipe>
card.
Let’s quickly run down the code in these components.
// src/App.js
import Header from "./components/Header";
import Recipe from "./components/Recipe";
function App() {
return (
<div>
<Header />
<Recipe />
</div>
);
}
export default App;
Code language: JavaScript (javascript)
🗒️ Note » For brevity, we omit all style code in this tutorial, except styles that relate to localization. You can get all the code for this demo app from GitHub, including styles. (The i18n-demo-start
directory contains the app before i18n.)
// src/components/Header.js
export default function Header() {
return (
<header>
<img
alt="App logo"
src="/noun-recipe-2701716.svg"
/>
<h1>Yomtaba</h1>
<h2>·recipe of the day</h2>
</header>
);
}
Code language: JavaScript (javascript)
// src/components/Recipe.js
import Nutrition from "./Nutrition";
export default function Recipe() {
return (
<main>
<h2>Today's recipe</h2>
<h3>Delightful Vegan Bean Burger</h3>
<div>
<div>
<img
src="/vegan_burger.jpg"
alt="Vegan burger on a wooden plate"
/>
</div>
<div>
<div>
<p>by Rabia Mousa</p>
<p>2023/6/20</p>
</div>
<div>
<p>⏲️ 40min</p>
<p>❤️ 2291</p>
</div>
<div>
<Nutrition />
</div>
</div>
</div>
</main>
);
}
Code language: JavaScript (javascript)
// src/components/Nutrition.js
export default function Nutrition() {
return (
<table>
<thead>
<tr>
<th colSpan={2}><h4>Nutrition</h4></th>
<th>% Daily Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Calories</td>
<td>151</td>
<td></td>
</tr>
<tr>
<td>Fat</td>
<td>1g</td>
<td>2%</td>
</tr>
<!-- ... -->
</tbody>
</table>
);
}
Code language: JavaScript (javascript)
That’s about it for our starter code. Of course, all of our values are hard-coded into our components, and very much in need of localization. Shall we?
🔗 Resource » Get all the starter code from GitHub if you want to code along as we localize this app. Copy the i18n-demo-start
directory, run npm install
, and you should be good to go.
How do I localize my app with react-intl?
From a bird’s eye view, here’s how we localize our app:
- Install react-intl.
- Wrap our app hierarchy in an
<IntlProvider>
. - Move hard-coded strings to translation message dictionaries.
- Use
<FormattedMessage>
andintl.formatMessage()
to display these translation messages in our components. - Localize our dates and numbers using react-intl’s
<FormattedDate>
and<FormattedNumber>
respectively (and their function equivalents).
How does all that work in practice? Let’s take a look.
How do I install and set up react-intl?
We install react-intl via NPM. From the command line, run the following.
npm install react-intl
Code language: Bash (bash)
Let’s set up some configuration. We’ll put our i18n logic under a new directory, src/i18n
.
// src/i18n/i18n-config.js
// We'll use the English-USA locale when
// our app loads. It will also be used as
// a fallback when there's a missing
// translation in another locale.
export const defaultLocale = "en-US";
// The locales our app supports. We'll work
// with English-USA and Arabic-Egypt here.
// Feel free to add any locales you want.
export const locales = {
// English translation message dictionary.
"en-US": {
// "x.y" is just a convention for keys, but
// any string will do here.
"app.title": "Yomtaba",
"app.tagline": "recipe of the day",
},
// Arabic translation message dictionary.
"ar-EG": {
// Note that a message has to use the
// same ID/key across locales.
"app.title": "يومباتا",
"app.tagline": "وصفة اليوم",
},
};
Code language: JavaScript (javascript)
We’ll revisit this configuration throughout this guide. Let’s continue our setup.
🔗 Resource » Installation is also covered in the official FormatJS installation docs.
The IntlProvider component
All of react-intl’s localization components need to be inside an <IntlProvider>
to work. <IntlProvider>
manages the active locale and translations, and ensures that any nested react-intl components show their values in the active locale. Let’s see this in action.
First, we’ll create an <I18n>
component to encapsulate the <IntlProvider>
. <I18n>
will wrap our <App>
and house i18n logic as we continue to build.
// src/i18n/I18n.js
import { IntlProvider } from "react-intl";
// Import the configuration we created earlier
import { defaultLocale, locales } from "./i18n-config";
export default function I18n(props) {
return (
{/* IntlProvider needs to be fed the active `locale`
as well as the translation `messages` of the
active locale. The `defaultLocale` is a
fallback when there is a missing translation. */}
<IntlProvider
locale={defaultLocale}
defaultLocale={defaultLocale}
messages={locales[defaultLocale]}
>
{props.children}
</IntlProvider>
);
}
Code language: JavaScript (javascript)
Alright, now we can wrap our entire app with <I18n>
; we’ll do so in index.js
.
// src/index.js
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
+ import I18n from "./i18n/I18n";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
+ <I18n>
+ <App />
+ </I18n>
</React.StrictMode>
);
Code language: Diff (diff)
Our hierarchy now looks like this:
.
└── I18n
└── IntlProvider
└── App
└── ...
Code language: plaintext (plaintext)
Our entire app is wrapped in a locale-aware <IntlProvider>
. We can now localize our components. We’ve already added translation messages for our app’s name and tagline; let’s use those to update our <Header>
. We’ll use react-intl’s <FormattedMessage>
component to do this.
// src/components/Header.js
+ import { FormattedMessage } from "react-intl";
export default function Header() {
return (
<header>
<img
alt="App logo"
src="/noun-recipe-2701716.svg"
/>
<h1>
- Yomtaba
+ <FormattedMessage id="app.title" />
</h1>
·
<h2>
- recipe of the day
+ <FormattedMessage id="app.tagline" />
</h2>
</header>
);
}
Code language: Diff (diff)
react-intl’s <FormattedMessage>
component takes an id
prop and renders the corresponding translation message in the active locale. The wrapping <IntlProvider>
will ensure that the active locale’s messages are used here.
If we reload our app now, it looks exactly the same as it did before. That’s because the active locale currently defaults to English.
What happens, however, if we change our default locale to Arabic?
// src/i18n/i18n-config.js
- export const defaultLocale = "en-US";
+ export const defaultLocale = "ar-EG";
export const locales = {
"en-US": {
"app.title": "Yomtaba",
"app.tagline": "recipe of the day",
},
"ar-EG": {
"app.title": "يومباتا",
"app.tagline": "وصفة اليوم",
},
};
Code language: Diff (diff)
Presto.
🤿 Go deeper » Read more about **FormattedMessage in the official API docs.
Using translation files
This is a good start, but we might want a more scaleable solution: All of our translations sitting in one configuration file can quickly get messy.
Let’s move our translations out of i18n-config.js
and into new, per-locale translation files. We’ll put those under a src/lang
directory.
// src/lang/en-US.json
{
"app.title": "Yomtaba",
"app.tagline": "recipe of the day"
}
Code language: JSON / JSON with Comments (json)
// src/lang/ar-EG.json
{
"app.title": "يومتابا",
"app.tagline": "وصفة اليوم"
}
Code language: JSON / JSON with Comments (json)
Now we can update our configuration to pull these files in.
// src/i18n/i18n-config.js
+ import enMessages from "../lang/en-US.json";
+ import arMessages from "../lang/ar-EG.json";
export const defaultLocale = "en-US";
export const locales = {
"en-US": {
+ messages: enMessages,
- "app.title": "Yomtaba",
- "app.tagline": "recipe of the day",
},
"ar-EG": {
+ messages: arMessages,
- "app.title": "يومتابا",
- "app.tagline": "وصفة اليوم"
},
};
Code language: Diff (diff)
We round out our refactor with a quick update to our <I18n>
component.
// src/i18n/I18n.js
import { IntlProvider } from "react-intl";
import { defaultLocale, locales } from "./i18n-config";
export default function I18n(props) {
return (
<IntlProvider
locale={defaultLocale}
defaultLocale={defaultLocale}
- messages={locales[defaultLocale]}
+ messages={locales[defaultLocale].messages}
>
{props.children}
</IntlProvider>
);
}
Code language: Diff (diff)
Our app should now work exactly as it did before, except now we can add more translations without bloating our i18n-config.js
file. We can also easily pass translation files back and forth to translators.
How do I add a language switcher?
Let’s give our users a nice UI to be able to select the locale of their choice. Adding a language switcher will also make testing easier as we continue to localize the app. Once done, it will look like this:
We’ll need to update <IntlProvider>
‘s locale
and messages
props to effectively change the rendered language. The problem is that our language switcher will need to sit deep inside the <IntlProvider>
, making our hierarchy look something like this:
.
└── I18n
└── IntlProvider
└── App
└── Header
└── LangSwitcher
└── select
├── option[English]
└── option[Arabic]
Code language: plaintext (plaintext)
We want to let <IntlProvider>
know when a new language is selected in the <LangSwitcher>
.
We could connect an event through the <Header>
and <App>
components to the <IntlProvider>
. This is known as “prop drilling”. It would create an unnecessary dependency chain, making it difficult to move the <LangSwitcher>
within the app hierarchy later.
To avoid prop drilling, let’s add a piece of React context to manage our global locale state.
// src/i18n/LocaleContext.js
import { createContext } from "react";
export const LocaleContext = createContext({
// Defaults that we'll override in a moment.
locale: "",
setLocale: () => {},
});
Code language: JavaScript (javascript)
Now let’s wire up this context to our <I18n>
component.
// src/i18n/I18n.js
import { IntlProvider } from "react-intl";
import { defaultLocale, locales } from "./i18n-config";
+ import { useState } from "react";
+ import { LocaleContext } from "./LocaleContext";
export default function I18n(props) {
+ // Add the active locale as component state.
+ const [locale, setLocale] = useState(defaultLocale);
return (
+ // Expose the state and its setter to all descendent
+ // components.
+ <LocaleContext.Provider value={{ locale, setLocale }}>
<IntlProvider
- locale={defaultLocale}
+ locale={locale}
defaultLocale={defaultLocale}
- messages={locales[defaultLocale].messages}
+ messages={locales[locale].messages}
>
{props.children}
</IntlProvider>
+ </LocaleContext.Provider>
);
}
Code language: Diff (diff)
We can now use LocaleContext
in any descendent of <LocaleContext.Provider>
, allowing us to set the top-level locale
state practically anywhere in our hierarchy.
This means we can update the active locale in <IntlProvider>
without passing props up or down our hierarchy. Let’s make use of this in our new <LangSwitcher>
.
// src/i18n/LangSwitcher.js
import { useContext } from "react";
import { locales } from "./i18n-config";
import { LocaleContext } from "./LocaleContext";
export default function LangSwitcher() {
// Pull in the top-level locale and its setter.
const { locale, setLocale } = useContext(LocaleContext);
return (
<div>
<select
value={locale}
// Whenever the user selects a locale, update the
// top-level active locale.
onChange={(e) => setLocale(e.target.value)}
>
{/* The keys of the `locales` config object
are the locale codes: "en-US", "ar-EG". */}
{Object.keys(locales).map((loc) => (
<option value={loc} key={loc}>
{loc}
</option>
))}
</select>
</div>
);
}
Code language: JavaScript (javascript)
With that in place, we can select a new locale from the UI, triggering a re-render of all react-intl components, like <FormattedMessage>
. This effectively switches the active locale. If we tuck our <LangSwitcher>
inside our <Header>
component, we should see the following.
Of course, we don’t want our users looking at locale codes like en-US
when selecting their language. Let’s add human-friendly names to our locales.
// src/i18n/i18n-config.js
// ...
export const locales = {
"en-US": {
+ name: "English",
messages: enMessages,
},
"ar-EG": {
+ name: "Arabic (العربية)",
messages: arMessages,
},
};
Code language: Diff (diff)
Now we can update our <LangSwitcher>
to use these names.
// src/i18n/LangSwitcher.js
// ...
export default function LangSwitcher() {
// ...
return (
<div>
<select ...>
{Object.keys(locales).map((loc) => (
<option value={loc} key={loc}>
- {loc}
+ {locales[loc].name}
</option>
))}
</select>
</div>
);
}
Code language: Diff (diff)
Our language switcher now looks more readable. And because we used context, our solution is flexible: We can move our <LangSwitcher>
anywhere within the scope of our <LocalContext.Provider>
and it will continue to work.
📣 Shoutout » Language icon by jonata hangga on the Noun Project.
🔗 Resource » The official React docs’ **Passing Data Deeply with Context is a good guide on the subject.
How do I work with text direction (LTR/RTL)?
Arabic, Hebrew, Maldivian, and other languages are laid out right-to-left (rtl
). Most others are left-to-right (ltr
). Web browsers accommodate this through the <html dir="rtl">
attribute, which sets the layout of the whole page. Let’s tap into this attribute in a new custom hook.
// src/i18n/useDocL10n.js
import { useEffect } from "react";
import { useIntl } from "react-intl";
import { locales } from "./i18n-config";
export function useDocL10n() {
// Get the active locale from the `intl`
// instance.
const { locale } = useIntl();
// Update the <html dir> attr whenever
// the locale changes.
useEffect(() => {
document.dir = locales[locale].dir;
}, [locale]);
}
Code language: JavaScript (javascript)
This is the first time we see the useIntl()
hook. It returns an intl
instance that has useful properties, like the active locale
. (This is the same intl
instance managed internally by our <IntlProvider>
). We’ll revisit useIntl()
throughout the article.
Let’s use our new custom hook in the root <App>
component.
// src/App.js
import Header from "./components/Header";
import Recipe from "./components/Recipe";
+ import { useDocL10n } from "./i18n/useDocL10n";
export default function App() {
+ useDocL10n();
return (
<div>
<Header />
<Recipe />
</div>
);
}
Code language: Diff (diff)
// src/App.js
import Header from "./components/Header";
import Recipe from "./components/Recipe";
+ import { useDocL10n } from "./i18n/useDocL10n";
export default function App() {
+ useDocL10n();
return (
<div>
<Header />
<Recipe />
</div>
);
}
Code language: Diff (diff)
Notice that we’re referencing locales[locale].dir
in our custom hook above. We need to add these direction configs to make the hook work.
// src/i18n/i18n-config.js
// ...
export const locales = {
"en-US": {
name: "English",
messages: enMessages,
+ dir: "ltr",
},
"ar-EG": {
name: "Arabic (العربية)",
messages: arMessages,
+ dir: "rtl",
},
};
Code language: Diff (diff)
Now when Arabic is the active locale, the <html>
document element will have a dir="rtl"
value; when English is active it has dir="ltr"
.
🗒️ Note » You can set the dir
attribute on many HTML elements to override the page’s dir
.
Updating horizontal styles
Setting the dir
attribute to rtl
on the <html>
will flow the page right-to-left. However, we often still need to update CSS that uses properties like margin-left
. Here’s an example:
/* Apply when the document is left-to-right. */
[dir="ltr"] .card {
padding-left: 0.25rem;
}
/* Apply when the document is right-to-left. */
[dir="rtl"] .card {
padding-right: 0.25rem;
}
Code language: CSS (css)
🤿 Go deeper » Alternatively, we can use the newer *logical* properties, like padding-inline-start
, which cover text direction automatically.
🗒️ Note » Tailwind CSS has built-in direction modifiers e.g. ltr:pr-1 rtl:pl-1
. The framework also supports logical properties, like ps-1
for padding-inline-start
.
What are the 2 ways of formatting in react-intl?
Let’s review the basic translation message workflow as we look at react-intl’s 2 ways of formatting: declarative and imperative.
We’ll add translations for our recipe headers next.
// src/lang/en-US.json
{
"app.title": "Yomtaba",
"app.tagline": "recipe of the day",
"app.logo_alt": "Yomtaba logo",
+ "recipe.title_label": "Today's recipe",
+ "recipe.title": "Delightful Vegan Bean Burger",
}
Code language: Diff (diff)
// src/lang/ar-EG.json
{
"app.title": "يومتابا",
"app.tagline": "وصفة اليوم",
"app.logo_alt": "رمز يومتابا",
+ "recipe.title_label": "وصفة اليوم",
+ "recipe.title": "برغر الفاصوليا النباتي الرائع",
}
Code language: Diff (diff)
Just as before, we’ll pull these translations into our <Recipe>
component using react-intl’s <FormattedMessage>
component.
// src/components/Recipe.js
+ import { FormattedMessage } from "react-intl";
// ...
export default function Recipe() {
return (
<main>
<h2>
- Today's recipe
+ <FormattedMessage id="recipe.title_label" />
</h2>
<h3>
- Delightful Vegan Bean Burger
+ <FormattedMessage id="recipe.title" />
</h3>
{/* ... */}
</main>
);
}
Code language: Diff (diff)
This is a declarative way to format messages. In other words, we’re not concerned with how messages as formatted; just that the React component, <FormattedMessage>
, will display a message in the active locale given its id
.
As it happens, <FormattedMessage>
uses an intl.formatMessage()
function under the hood —that’s the how, the imperative way to format.
We can make use of intl.formatMessage()
ourselves through the useIntl()
hook. This comes in handy when we want to translate attributes or props. Let’s translate our recipe image’s alt
attribute to illustrate.
// src/lang/en-US.json
{
// ...
"recipe.title_label": "Today's recipe",
"recipe.title": "Delightful Vegan Bean Burger",
+ "recipe.img_alt": "Vegan burger on a wooden plate",
}
Code language: Diff (diff)
// src/lang/ar-EG.json
{
// ...
"recipe.title_label": "وصفة اليوم",
"recipe.title": "برغر الفاصوليا النباتي الرائع",
+ "recipe.img_alt": "برغر نباتي على طبق خشبي",
}
Code language: Diff (diff)
// src/components/Recipe.js
- import { FormattedMessage } from "react-intl";
+ import { FormattedMessage, useIntl } from "react-intl";
// ...
export default function Recipe() {
+ // Retrieve the `intl` object holding the active
+ // locale and translation messages. This object
+ // is provided by the top-level `<IntlProvider>`.
+ const intl = useIntl();
return (
<main>
<h2>
<FormattedMessage id="recipe.title_label" />
</h2>
<h3>
<FormattedMessage id="recipe.title" />
</h3>
<div>
<div>
<img
src="/vegan_burger.jpg"
- alt="Vegan burger on a wooden plate"
+ alt={intl.formatMessage({ id: "recipe.img_alt" })}
/>
</div>
{/* ... */}
</div>
</main>
);
}
Code language: JavaScript (javascript)
intl.formatMessage()
works much like the <FormattedMessage>
component: Given an id
, it returns the corresponding translation message in the active locale.
🔗 Resource » Learn more about intl.formatMessage()
in the FormatJS API docs.
Localizing the document title
Now that we know about formatMessage()
, we can use it to translate our <html title>
in our useDocL10n()
custom hook.
// src/i18n/useDocL10n.js
import { useEffect } from "react";
import { useIntl } from "react-intl";
import { locales } from "./i18n-config";
export function useDocL10n() {
- const { locale } = useIntl();
+ const { locale, formatMessage } = useIntl();
useEffect(() => {
document.dir = locales[locale].dir;
+ // Localize the <html title> attribute.
+ document.title = formatMessage({ id: "app.title" });
- }, [locale]);
+ }, [locale, formatMessage]);
}
Code language: Diff (diff)
With that, our document title is localized.
How do I add dynamic values to translation messages?
We often want to inject runtime values in a translation string. This could be the name of the logged-in user, for example. The ICU message format used by FormatJS makes this interpolation easy. Let’s localize the author string of our recipe to demonstrate.
First, we’ll add new translation messages, and use the {variable}
syntax for placeholders.
// src/lang/en-US.json
{
// ...
"recipe.title": "Delightful Vegan Bean Burger",
"recipe.img_alt": "Vegan burger on a wooden plate",
+ // {author} and {publishedAt} will be
+ // replaced at runtime
+ "recipe.author": "by {author} on {publishedAt}"
}
Code language: Diff (diff)
// src/lang/en-US.json
{
// ...
"recipe.title": "Delightful Vegan Bean Burger",
"recipe.img_alt": "Vegan burger on a wooden plate",
+ // {author} and {publishedAt} will be
+ // replaced at runtime
+ "recipe.author": "by {author} on {publishedAt}"
}
Code language: Diff (diff)
Now we can use the values
prop in <FormattedMessage>
to interpolate these dynamic values at runtime.
// src/components/Recipe.js
import { FormattedMessage, useIntl } from "react-intl";
// ...
export default function Recipe() {
const intl = useIntl();
return (
<main>
{/* ... */}
<div>
{/* ... */}
<div>
<div>
<p>
+ {/* We specify the `values` we want to swap in
+ via a key/value map. */}
+ <FormattedMessage
+ id="recipe.author"
+ values={{ author: "Rabia Mousa", publishedAt: "2023/06/20" }}
+ />
</p>
</div>
{/* ... */}
</div>
</div>
</main>
);
}
Code language: Diff (diff)
That will do it.
🗒️ Note » The date in Arabic looks off: It’s injected as a hard-coded string and hasn’t been localized. We’ll fix it when we get to date formatting a bit later.
Of course, the imperative intl.formatMessage()
provides interpolation as well.
<p>
{/* The second parameter to `formatMessage` can be
a map for swapping in dynamic values. */}
{intl.formatMessage(
{ id: "recipe.author" },
{ author: "Rabia Mousa", publishedAt: "2023/06/20" }
)}
</p>
Code language: JavaScript (javascript)
How do I work with plurals in translation messages?
“You have 1 new message.”
“You have 29 new messages.”
Ah, plurals. They’re often mishandled in translation. It’s important to realize that different languages have different plural forms. While English has two plural forms*,* one
and other
, other languages can have more. Arabic, for example, has six.
🔗 Resource » The CLDR Language Plural Rules chart is a canonical source for languages’ plural forms.
Let’s add a comment counter to our recipe to showcase localized plurals.
We’ll add the English translation message first. The ICU message format has excellent support for plurals, using a special syntax:
{count, plural,
one {# comment}
other {# comments}
}
Code language: plaintext (plaintext)
✋ Heads up » The other
form is always required.
Of course, we need to add this message to our JSON language files. JSON doesn’t support multiline strings, so our message ends up looking like the following.
// src/lang/en-US.json
{
// ...
"recipe.author": "by {author}",
+ "recipe.comment_count": "{count, plural, one {# comment} other {# comments}}"
}
Code language: Diff (diff)
The count
variable in the message represents an integer that we can pass to <FormattedMessage>
via its values
prop.
// src/components/Recipe.js
import { FormattedMessage, useIntl } from "react-intl";
// ...
export default function Recipe() {
// ...
return (
<main>
{/* ... */}
<div>
<p>⏲️ 40min</p>
<p>❤️ 2291</p>
+ <p>
+ <FormattedMessage
+ id="recipe.comment_count"
+ values={{ count: 419 }}
+
+ />
+ </p>
</div>
{/* ... */}
</main>
);
}
Code language: Diff (diff)
FormatJS uses the count
variable to determine the plural form within the recipe.comment_count
message. It also replaces instances of #
with the value of count
in the string. In English, this results in the following renders for count
values of 0
, 1
, and 2
, respectively.
Our Arabic translation message is more complex because it includes the language’s six plural forms.
{count, plural,
zero {لا توجد تعليقات}
one {تعليق #}
two {تعليقين #}
few {# تعليقات}
many {# تعليق}
other {# تعليق}
}
Code language: plaintext (plaintext)
Again, we need to squish it into one line for our JSON.
// src/lang/ar-EG.json
{
// ...
"recipe.author": "من {author}",
+ "recipe.comment_count": "{count, plural, zero {لا توجد تعليقات} one {تعليق #} two {تعليقين #} few {# تعليقات} many {# تعليق} other {# تعليق}}"
}
Code language: Diff (diff)
🔗 Resource » I found a handy Online ICU Message Editor that I used for testing my multiline plural strings before copying them into my JSON and removing newline characters.
With our new translation in place, we get perfectly pluralized messages when we switch our active locale to Arabic. Also notice that the Arabic messages have Eastern Arabic numerals (١، ٢، ٣), which is correct for the Arabic-Egypt locale.
🤿 Go deeper » The Missing Guide to the ICU Message Format covers all the ins and outs of ICU message plurals. And our Concise Guide to Number Localization goes into detail regarding numeral systems.
How do I localize numbers?
We’ve just seen that we need to display numbers appropriately for the active locale. Not all locales use Western Arabic numerals (1, 2, 3). Bengali, for example, uses the Bengali–Assamese numeral system (০, ১, ২, ৩). Currency and percent symbols, large number separators (1,000,000), and more are specific to each region.
✋ Heads up » This is why it’s important to use region-specific locale codes when working with numbers and dates. Use en-US
, not en
, for example.
Luckily, react-intl/FormatJS has good number localization support, which uses the JavaScript standard Intl.NumberFormat under the hood. Let’s make use of these react-intl features to localize the numbers in our React app.
We’ll start with the recipe “likes” counter.
Here we can keep it simple and use react-intl’s <FormattedNumber>
component.
// src/components/Recipe.js
- import { FormattedMessage, useIntl } from "react-intl";
+ import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
export default function Recipe() {
const intl = useIntl();
return (
<main>
{/* ... */}
<div>
<p>⏲️ 40min</p>
<p>
- ❤️ 2291
+ ❤️ <FormattedNumber value={2291} />
</p>
<p>
<FormattedMessage
id="recipe.comment_count"
values={{ count: 419 }}
/>
</p>
</div>
{/* ... */}
</main>
);
}
Code language: Diff (diff)
<FormattedNumber>
localizes its given number value
to the active locale.
We can customize <FormattedNumber>
‘s output: Anything that can be passed to the Intl.NumberFormat constructor options
parameter can be passed as a prop to <FormattedNumber>
. Here are some examples:
We can pass the same options to react-intl’s imperative equivalent, intl.formatNumber()
.
<p>
{intl.formatNumber(0.11, { style: "percent" })}
</p>
{/*
en-US → "11%"
ar-EG → "٪١١"
*/}
Code language: JavaScript (javascript)
What about numbers interpolated in our translation messages? The ICU message syntax has us covered here.
// English
{
"recipe.ingredient_price": "Estimated cost {cost, number, ::currency/USD}"
}
// Arabic
{
"recipe.ingredient_price": "التكلفة التقديرية {cost, number, ::currency/USD}"
}
Code language: JSON / JSON with Comments (json)
The syntax is similar to the plural syntax we used earlier. A last, optional, format designator after the ::
is passed to the Intl.NumberFormat
constructor.
🤿 Go deeper » The strange currency/USD
syntax is a part of the ICU syntax known as a number skeleton.
// In our components, given the above messages...
<p>
<FormattedMessage
id="recipe.ingredient_price"
values={{ cost: 18.42 }}
/>
</p>
Code language: JavaScript (javascript)
The above would render the following in English and Arabic, respectively.
How do I localize dates?
Localizing dates with react-intl is very similar to localizing numbers. We use react-intl’s <FormatteDate>
and intl.formatDate()
. In turn, react-intl uses the JavaScript standard Intl.DateTimeFormat under the hood to localize our dates.
✋ Heads up » Just like numbers, dates are region-specific. So favor locales with regions (ar-EG
) over language-only locales (ar
).
Let’s revisit our earlier interpolation example.
// src/lang/en-US.json
{
// ...
"recipe.author": "by {author} on {publishedAt}",
// ...
}
Code language: JSON / JSON with Comments (json)
// src/lang/ar-EG.json
{
// ...
"recipe.author": "من {author} في {publishedAt}",
// ...
}
Code language: JSON / JSON with Comments (json)
// src/components/Recipe.js
// ...
export default function Recipe() {
// ...
return (
<main>
{/* ... */}
<div>
<p>
<FormattedMessage
id="recipe.author"
values={{
author: "Rabia Mousa",
publishedAt: "2023/06/20",
}}
/>
</p>
</div>
{/* ... */}
</main>
);
}
Code language: JavaScript (javascript)
The above results in publishedAt
rendering as “2023/06/20” in both English and Arabic. We can improve this by localizing the date value.
First, let’s update the publishedAt
value itself, changing it to a Date
object, which FormatJS needs for date localization.
// src/components/Recipe.js
// ...
export default function Recipe() {
// ...
return (
{/* ... */}
<p>
<FormattedMessage
id="recipe.author"
values={{
author: "Rabia Mousa",
- publishedAt: "2023/06/20",
+ // Remember, months are zero-indexed in
+ // the `Date` constructor
+ // ie. `0` is January.
+ publishedAt: new Date(2023, 5, 20),
}}
/>
</p>
{/* ... */}
);
}
Code language: Diff (diff)
Now let’s use the ICU message syntax to designate a medium-length date in our messages.
// src/lang/en-US.json
{
// ...
- "recipe.author": "by {author} on {publishedAt}",
+ "recipe.author": "by {author} on {publishedAt, date, medium}",
// ...
}
Code language: Diff (diff)
// src/lang/ar-EG.json
{
// ...
- "recipe.author": "من {author} في {publishedAt}",
+ "recipe.author": "من {author} في {publishedAt, date, medium}",
// ...
}
Code language: Diff (diff)
With that, our date is localized.
Built-in short
, medium
, and long
date formats are always available to us. We can also use ICU datetime skeletons to granularly control our formats. Let’s say we wanted to remove the day from our recipe publishing date. ICU date skeletons come in handy here:
// src/lang/en-US.json
{
// ...
- "recipe.author": "by {author} on {publishedAt, date, medium}",
+ // Use month number (`M`) and two-digit year (`yy`) format.
+ "recipe.author": "by {author} on {publishedAt, date, ::Myy}",
// ...
}
Code language: Diff (diff)
// src/lang/ar-EG.json
{
// ...
- "recipe.author": "من {author} في {publishedAt, date, medium}",
+ // Using inline message formats allows us to format dates
+ // differently for each locale. Here we use the full name of
+ // the month (`MMM`) and the four-digit year (`yyyy`).
+ "recipe.author": "من {author} في {publishedAt, date, ::MMMyyyy}",
// ...
}
Code language: Diff (diff)
🔗 Resource » Find all the date skeletons FormatJS supports in the official docs.
What if we wanted to format a date outside of a translation message? We can use react-intl’s <FormattedDate>
component for that. Let’s break our recipe’s publish date out of the translation message.
// src/components/Recipe.js
// ...
export default function Recipe() {
// ...
return (
{/* ... */}
<p>
<FormattedMessage
id="recipe.author"
values={{
author: "Rabia Mousa",
- // Of course, we need to remove
- // `publishedAt` from our messages
- // as well.
- publishedAt: new Date(2023, 5, 20),
}}
/>
</p>
+ <p>
+ <FormattedDate
+ value={new Date(2023, 5, 20)}
+ dateStyle="short"
+ />
+ </p>
{/* ... */}
);
}
Code language: Diff (diff)
Much like <FormattedNumber>
, <FormattedDate>
takes formatting props that match the options
param passed to the Intl.DateTimeFormat constructor.
And, of course, the imperative intl.formatDate()
can be used to get the same result:
// src/components/Recipe.js
// ...
export default function Recipe() {
// ...
return (
{/* ... */}
<p>
- <FormattedDate
- value={new Date(2023, 5, 20)}
- dateStyle="short"
- />
+ // Renders the same result as the above
+ {intl.formatDate(new Date(2023, 5, 20), {
+ dateStyle: "short",
+ })}
</p>
{/* ... */}
);
}
Code language: Diff (diff)
🤿 Go deeper » react-intl offers many more datetime formatting options, including time, datetime range, relative time, and time zone support. See the component and imperative API docs for more info (the right-hand sidebars list the relevant components and functions, respectively).
How do I load translations dynamically?
Our current solution still isn’t as scalable as it could be. If we had a hundred, or even twenty locales, we would load them all in our main JavaScript bundle. This would waste bandwidth and slow down our website.
We can fix this. Let’s refactor our translation message logic so that we only load messages for the active locale. We can do this by dynamically loading the appropriate message file once we’ve determined the active locale.
First, let’s remove the static message file imports from our config file. We won’t need them anymore.
// src/i18n/i18n-config.js
- import enMessages from "../lang/en-US.json";
- import arMessages from "../lang/ar-EG.json";
export const defaultLocale = "en-US";
export const locales = {
"en-US": {
name: "English",
- messages: enMessages,
dir: "ltr",
},
"ar-EG": {
name: "Arabic (العربية)",
- messages: arMessages,
dir: "rtl",
},
};
Code language: Diff (diff)
Next, let’s update our <I18n>
wrapper component to load the appropriate translation file dynamically.
// src/i18n/I18n.js
- import { useState } from "react";
+ import { useEffect, useState } from "react";
import { IntlProvider } from "react-intl";
- import { defaultLocale, locales } from "./i18n-config";
+ import { defaultLocale } from "./i18n-config";
import { LocaleContext } from "./LocaleContext";
export default function I18n(props) {
const [locale, setLocale] = useState(defaultLocale);
+ // We hold the loaded translation messages in
+ // a piece of state so we can set it in our
+ // useEffect and use it in our JSX.
+ const [messages, setMessages] = useState(null);
+ useEffect(() => {
+ // Force a re-render that shows our
+ // loading indicator.
+ setMessages(null);
+
+ // Load the messages using Webpack code-splitting
+ // via dynamic import.
+ import(`../lang/${locale}.json`)
+ .then((messages_) => setMessages(messages_))
+ .catch((err) =>
+ console.error(`Error loading messages for locale ${locale}: `, err)
+ );
+ }, [locale]);
- return (
+ return !messages ? (
+ <p>Loading...</p>
+ ) : (
<LocaleContext.Provider value={{ locale, setLocale }}>
<IntlProvider
locale={locale}
defaultLocale={defaultLocale}
- messages={locales[locale].messages}
+ messages={messages}
>
{props.children}
</IntlProvider>
</LocaleContext.Provider>
);
}
Code language: Diff (diff)
If we open our browser dev tools and throttle our network speed to simulate a slow connection, we should be able to see our new loading state.
Under the hood, the Webpack bundler that Create React App uses has turned our dynamic import into a chunk that loads separately from the main bundle. We can see this if we open our browser Network tab.
Our app works almost exactly as before, except now we can have as many locales as we want without bloating our main bundle.
How do I detect the user’s preferred language?
Not everyone can read English, and a good i18n strategy should accommodate that. Our user’s preferred languages are available to us through her browser’s navigator
object. We can use this to load our app shown in the supported language closest to the user’s preference.
Let’s do this step-by-step. First, we’ll write a helper that returns an array of the locales set in the browser.
// src/i18n/browser-locales.js
export function browserLocales() {
const result = [];
// List of languages the user set in their
// browser settings.
if (navigator.languages) {
for (let lang of navigator.languages) {
result.push(lang);
}
}
// UI language: language of browser and probably
// operating system.
if (navigator.language) {
result.push(navigator.language);
}
return result;
}
Code language: JavaScript (javascript)
navigator.languages
corresponds to the list you can set in your own browser settings.
In case none are set in our user’s browser, we fall back on navigator.language
.
We’ll use the browser locale to match one of our app’s supported locales. FormatJS has a handy locale matcher package, intl-localematcher, to help with just this. Let’s install it from the command line.
npm install @formatjs/intl-localematcher
Code language: Bash (bash)
Now we write a function that takes both the browser locales and our app’s defined locales, and tries to find the closest match.
// src/i18n/user-locale.js
import { match } from "@formatjs/intl-localematcher";
import { defaultLocale, locales } from "./i18n-config";
import { browserLocales } from "./browser-locales";
export function userLocale() {
const appLocales = Object.keys(locales);
return match(browserLocales(), appLocales, defaultLocale);
}
Code language: JavaScript (javascript)
FormatJS’s match
is doing the heavy lifting here. Let’s briefly go over how it works.
Say we have Arabic-Morocco (ar-MA
) and English-Canada (en-CA
), in that order, as our preferred browser languages.
We know that our app’s locales are Arabic-Egypt (ar-EG
) and English-USA (en-US
). So in our scenario, the match()
call above is equivalent to the following.
export function userLocale() {
return match(
["ar-MA", "en-CA", "ar-MA"], // browser locales
["en-US", "ar-EG"], // app locales
"en-US" // default locale
);
}
Code language: JavaScript (javascript)
match
looks at the browser locales and realizes that, ideally, our user wants content served in Arabic for the Morocco region. It checks against the second list and sees that while we don’t have that exact flavor of Arabic, we do have Egyptian Arabic. That’s the best our app can do, so match
returns ar-EG
.
🗒️ Note » When all else fails, match
returns the value of its last param, the default locale.
Alright, let’s make use of this logic by wiring it up to the <I18n>
component.
// src/i18n/I18n.js
import { useEffect, useState } from "react";
import { IntlProvider } from "react-intl";
import { defaultLocale } from "./i18n-config";
import { LocaleContext } from "./LocaleContext";
+ import { userLocale } from "./user-locale";
export default function I18n(props) {
- const [locale, setLocale] = useState(defaultLocale);
+ const [locale, setLocale] = useState(userLocale());
const [messages, setMessages] = useState(null);
// ...
}
Code language: Diff (diff)
Now when a user visits our app, they’ll see it translated in a locale that’s closest to their preferred language.
🔗 Resource » Learn more in Detecting a User’s Locale in a Web App.
Storing the user-selected locale
What if the user wants to pick a language themselves, overriding the one we matched for them? We already have a language selector to allow them to do that. The problem now is that every time they visit our app, we’ll always use the detected/matched locale, ignoring any selection the user made in a previous visit.
We can fix this by utilizing the browser’s localStorage (that’s local not locale). localStorage
allows us to store key/value pairs on the browser that we can retrieve on future site visits. Let’s add a simple module to store the user’s selected locale.
// src/i18n/stored-locale.js
const K_LOCALE = "locale";
// Retrieve the locale persisted in the browser.
export function getStoredLocale() {
return localStorage.getItem(K_LOCALE);
}
// Persist the given locale in the browser.
export function setStoredLocale(newLocale) {
localStorage.setItem(K_LOCALE, newLocale);
}
Code language: JavaScript (javascript)
Now let’s make sure that if a user selects a locale using our language switcher, it gets stored for later retrieval.
// src/i18n/LangSwitcher.js
import { useContext } from "react";
import { locales } from "./i18n-config";
import { LocaleContext } from "./LocaleContext";
+ import { setStoredLocale } from "./stored-locale";
export default function LangSwitcher({ onLangChanged }) {
const { locale, setLocale } = useContext(LocaleContext);
return (
<div>
{/* ... */}
<select
value={locale}
onChange={(e) => {
setLocale(e.target.value);
+ setStoredLocale(e.target.value);
}}
>
{Object.keys(locales).map((loc) => (
<option value={loc} key={loc}>
{locales[loc].name}
</option>
))}
</select>
</div>
);
}
Code language: Diff (diff)
Finally, let’s update our userLocale()
function to return the stored locale if it finds it.
// src/i18n/user-locale.js
import { match } from "@formatjs/intl-localematcher";
import { defaultLocale, locales } from "./i18n-config";
import { browserLocales } from "./browser-locales";
+ import { getStoredLocale } from "./stored-locale";
export function userLocale() {
+ const storedLocale = getStoredLocale();
+ if (storedLocale) return storedLocale;
const appLocales = Object.keys(locales);
return match(browserLocales(), appLocales, defaultLocale);
}
Code language: Diff (diff)
That’s it! Now if the user selects a locale from our language switcher, it will always be used on future visits, regardless of their browser settings.
How do I include HTML in my translation messages?
Sometimes we want to embed links or special styles within our translation messages. For example:
// In a component
<p>
Try our <a href="/trial-landing">premium recipes</a> for free!
</p>
Code language: JavaScript (javascript)
Luckily, react-intl makes it easy to embed markup in our translation messages using rich text formatting. Here’s how we can use it to translate the above message:
// English translation
{
// Note the embedded <a> tag.
"trial": "Try our <a>premium recipes</a> for free!"
}
// Arabic translation
{
"trial": "جرب <a>وصفاتنا المميزة</a> مجانًا!"
}
Code language: JSON / JSON with Comments (json)
// In our component
// We match the <a> with an `a` in our `values` object.
<p>
<FormattedMessage
id="trial"
values={{
// `a` is a function that takes a param
// containing what is *inside* <a></a>
// in our message, and returns valid JSX.
a: (chunks) => <a href="/trial-landing">{chunks}</a>
}}
/>
</p>
Code language: JavaScript (javascript)
The above would render:
<!-- English -->
<p>
Try our <a href="/trial-landing">premium recipes</a> for free!
</p>
<!-- Arabic -->
<p>
جرب <a href="/trial-landing">وصفاتنا المميزة</a> مجانًا!
</p>
Code language: HTML, XML (xml)
We can have as many embedded tags in our messages as we want, as long as they have corresponding resolver functions in FormattedMessage
‘s value
prop.
And, of course, we can use rich text formatting in intl.formattedMessage()
as well.
// Works just like <FormattedMessage>
<p>
{intl.formatMessage(
{ id: "trial" },
{
a: (chunks) => <a href="/trial-landing">{chunks}</a>,
}
)}
</p>
Code language: JavaScript (javascript)
🔗 Resource » Read more about rich text formatting in the official API documentation.
🗒️ Note » In earlier versions of react-intl, rich text formatting was achieved with a <FormattedHtmlMessage>
component. This has been deprecated in favor of the above solution.
How do I extract translations from my app?
So far, we’ve covered a workflow where we manually set messages in translation files and reference them by ID in our components. As our app scales, this approach can become cumbersome, as we have to manually maintain increasingly large translation files.
As our app scales, we can automate this process. Let’s start with message extraction. Instead of placing messages in translation files ourselves, we can have the FormatJS CLI do it for us.
Additional packages used
For our extraction workflow, we will use the following NPM packages:
Library | Version used | Description |
@formatjs/cli@6.1.3 | 6.1.3 | CLI used to extract and compile translations |
babel-plugin-formatjs | 10.5.3 | Auto-generates message IDs in our app builds |
react-app-rewired | 2.2.1 | Allows overriding Create React App webpack config (for including babel-plugin-formatjs) |
customize-cra | 1.0.0 | Allows overriding Create React App webpack config (for including babel-plugin-formatjs) |
We’ll start by installing the FormatJS CLI, which will allow us to extract our messages. Running the following command from the command line should do it.
npm install --save-dev @formatjs/cli
Code language: Bash (bash)
Let’s add a new script to our package.json
file to make our lives easier.
// package.json
{
"name": "i18n-demo",
"version": "0.1.0",
"private": true,
"dependencies": {
// ...
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
+ "extract": "formatjs extract 'src/**/*.js*' --out-file src/lang/en-US.json --ignore 'src/lang/**/*' --format simple",
},
// ...
}
Code language: Diff (diff)
Our new extract
script will use the FormatJS CLI to pull messages out of the .js
and .jsx
files in our source directory. The extracted messages will go into our source language file, src/lang/en-US.json
.
✋ Heads up » To avoid errors during extraction, we use the --ignore
flag to tell the CLI to skip our translations (lang
) directory, when looking for messages to extract.
Designating --format simple
keeps the outputted en-US.json
file in the {"id": "translation"}
format we’ve been using so far. You can use the default FormatJS format by omitting the --format
option. We’ll use the simple format in this tutorial.
🔗 Resource » Read about message extraction in the official docs. You might also find the CLI documentation handy.
If we run our extraction command now, en-US.json
will contain ID keys and empty translation messages. That’s because, with an extraction workflow, we need to add our source/default (English) messages directly to our components. Let’s update our Header
component to demonstrate.
// src/components/Header.js
import { FormattedMessage, useIntl } from "react-intl";
import LangSwitcher from "../i18n/LangSwitcher";
export default function Header() {
const intl = useIntl();
return (
<header>
<div>
<img
alt={intl.formatMessage({
id: "app.logo_alt",
+ defaultMessage: "Yomtaba logo",
})}
src="/noun-recipe-2701716.svg"
/>
<h1>
- <FormattedMessage id="app.title" />
+ <FormattedMessage id="app.title" defaultMessage="Yomtaba" />
</h1>
·
<h2>
- <FormattedMessage id="app.tagline" />
+ <FormattedMessage
+ id="app.tagline"
+ defaultMessage="recipe of the day"
+ />
</h2>
</div>
<LangSwitcher />
</header>
);
}
Code language: Diff (diff)
The defaultMessage
s we pass to formatMessage()
and FormattedMessage
are what the extract
command will pull from our source code. They should be in the app’s default locale, English in our case.
🗒️ Note » When a translation is missing from the active locale, the contents of defaultMessage
will be shown as a fallback.
✋ Heads up » We need to make sure that the app.title
declaration in our useDocL10n()
hook matches the one in the Header
component. Otherwise, we’ll get a warning that the messages don’t match when we try to extract (and an empty message in our translation file).
// src/i18n/useDocL10n.js
import { useEffect } from "react";
import { useIntl } from "react-intl";
import { locales } from "./i18n-config";
export function useDocL10n() {
const { locale, formatMessage } = useIntl();
useEffect(() => {
document.dir = locales[locale].dir;
- document.title = formatMessage({ id: "app.title" });
+ document.title = formatMessage({
+ id: "app.title",
+ defaultMessage: "Yomtaba",
+ });
}, [locale, formatMessage]);
}
Code language: Diff (diff)
Now let’s run our new command from the command line.
✋ Heads up » Please make sure that every translation message in your app has a defaultMessage
before running extract
, or you will lose its translation in your source locale file.
npm run extract
Code language: Bash (bash)
This should have overwritten our src/lang/en-US.json
file, which should largely unchanged.
// src/lang/en-US.json
{
"app.logo_alt": "Yomtaba logo",
"app.tagline": "recipe of the day",
"app.title": "Yomtaba"
// ...
}
Code language: JSON / JSON with Comments (json)
Except now we don’t have to maintain it manually. We can add and update translation messages in our app and npm run extract
to update the translation file.
✋ Heads up » The built-in react-intl React components will work fine with extract
, as well as intl.formatMessage()
. However, if you use any other components or functions for translation (like your own wrappers) you need to declare them using the CLI’s additional-function-names or additional-component-names options.
Of course, the translation workflow remains the same from here. We copy the en-US.json
file to ar-EG.json
, have a translator add the Arabic, and run the app as usual.
🔗 Resource » The i18n-demo-extraction directory in our GitHub repo has all of the code that we cover in this section.
Auto-generating IDs
We can further streamline our i18n process by removing message IDs from our source code entirely. Instead, FormatJS can automatically generate message IDs and use them under the hood.
🗒️ Note » FormatJS uses a hashing algorithm to generate message IDs that look like "7pboUV"
. It needs to be able to run the same algorithm when our app builds or runs in development so that it always generates the same ID for a given message. We’ll see all this in action as we go.
First, let’s remove the explicit custom IDs from our message definitions.
// src/components/Header.js
import { FormattedMessage, useIntl } from "react-intl";
import LangSwitcher from "../i18n/LangSwitcher";
export default function Header() {
const intl = useIntl();
return (
<header>
<div>
<img
alt={intl.formatMessage({
- id: "app.logo_alt",
defaultMessage: "Yomtaba logo",
})}
src="/noun-recipe-2701716.svg"
/>
<h1>
<FormattedMessage
- id="app.title"
defaultMessage="Yomtaba"
/>
</h1>
·
<h2>
<FormattedMessage
- id="app.tagline"
defaultMessage="recipe of the day"
/>
</h2>
</div>
<LangSwitcher />
</header>
);
}
Code language: Diff (diff)
Next, let’s modify the extract
command so that it auto-generates message IDs.
// package.json
{
"name": "i18n-demo",
"version": "0.1.0",
"private": true,
"dependencies": {
// ...
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-scripts eject",
- "extract": "formatjs extract 'src/**/*.js*' --out-file src/lang/en-US.json --ignore 'src/lang/**/*' --format simple"
+ "extract": "formatjs extract 'src/**/*.js*' --out-file src/lang/en-US.json --ignore 'src/lang/**/*' --format simple --id-interpolation-pattern '[sha512:contenthash:base64:6]'"
},
// ...
}
Code language: Diff (diff)
The --id-interpolation-pattern
option tells the extract
command to generate a SHA-512 hash as an ID for any message that doesn’t have an explicit ID. (We don’t have to worry too much about the specifics of the hashing algorithm for our purposes).
Let’s run the npm run extract
command to see the output. If we look at our English translation file now, we’ll see auto-generated IDs.
// src/lang/en-US.json
{
"KDE5tg": "Yomtaba",
"LwQO24": "recipe of the day",
"ykciPr": "Yomtaba logo"
}
Code language: JSON / JSON with Comments (json)
If we run our app now, we’ll get missing translation errors: FormatJS isn’t using its hashing algorithm at runtime, so it doesn’t know how to connect a message to its translation in a translation file.
To fix this, we need to use FormatJS’s Babel plugin, which means we need to update the Webpack configuration of our app. Create React App hides this configuration by default, so we’ll use react-app-rewired and customize-cra to hack into it.
✋ Heads up » Create React App maintains its own build scripts to keep you focused on building your app. When you circumvent and update CRA’s build scripts, you’re responsible for maintaining these updates.
First, we’ll install the packages.
npm install --save-dev babel-plugin-formatjs react-app-rewired customize-cra
Code language: Bash (bash)
Next, let’s create a .babelrc
file to tell Babel to use the FormatJS plugin.
// .babelrc
{
"plugins": [
[
"formatjs",
{
"idInterpolationPattern": "[sha512:contenthash:base64:6]",
"ast": true
}
]
]
}
Code language: JSON / JSON with Comments (json)
Now let’s create a config-overrides.js
file; we’ll use it to configure react-app-rewired so that it feeds our .babelrc
to Babel when running our app.
// config-overrides.js
const { useBabelRc, override } = require("customize-cra");
module.exports = override(useBabelRc());
Code language: JavaScript (javascript)
Our last step here is to bypass Create React App’s stock scripts and use the ones supplied by react-app-rewired.
// package.json
{
"name": "i18n-demo",
"version": "0.1.0",
"private": true,
"dependencies": {
// ...
},
"scripts": {
- "start": "react-scripts start",
+ "start": "react-app-rewired start",
- "build": "react-scripts build",
+ "build": "react-app-rewired build",
- "test": "react-scripts test",
+ "test": "react-app-rewired test",
"eject": "react-scripts eject",
"extract": "formatjs extract 'src/**/*.js*' --out-file src/lang/en-US.json --ignore 'src/lang/**/*' --format simple --id-interpolation-pattern '[sha512:contenthash:base64:6]'"
},
// ...
}
Code language: Diff (diff)
Now when we run our app, FormatJS runs its hash algorithm to re-generate its IDs, and our app works without errors.
🤿 Go deeper » We can also compile messages before distributing our app. This has a few benefits, including potentially speeding up our app. Read the FormatJS Message Distribution for more info on compiling.
Streamlining our localization process allows us to focus on the creative code of our app. With one command, we can create a translation source file to hand off to our translators, who can in turn return translations in ten languages, or a hundred.
And instead of maintaining somewhat cryptic message IDs, defaultMessage
s tell us exactly what a string in our UI says and means.
// In our components
<a href="/new-ingredient">
{/* Default messages make UI strings clear,
and not maintaining IDs or translation files
means we work more quickly. */}
<FormattedMessage defaultMessage="Add ingredient" />
</a>
Code language: JavaScript (javascript)
How do I integrate react-intl with Phrase Strings?
We can make our jobs, and those of our translators, even easier by adopting a software localization platform like Phrase Strings. A single CLI command can push our latest source translations to Phrase Strings, where our translators can use a powerful translation admin panel to translate our app into many locales. With another command, we can pull the updated translations into our app. This keeps us focused on building our app, and not on the localization.
Creating the Phrase Strings project
To integrate Phrase Strings into your app, you need to configure a new Phrase Strings project:
- Create a Phrase account (you can start for free).
- Login, open Phrase Strings, and click the New Project button near the top of the screen to create a project.
- Configure the project to use the React Intl Simple translation file format
- Add starting languages. In our case, we can add
en-US
first as the default locale, then addar-EG
. - Generate an access token from your profile page. (Click the user avatar near the top-right of the screen → Settings → Profile → Access tokens → Generate Token). Make a copy of this token somewhere safe.
- Open your new project and go to More → Settings. Check Enable ICU Message format support and click Save.
Setting up the Phrase Strings CLI
That’s it for the Phrase Strings project config. Now let’s set up the Phrase Strings CLI so we can automate the transfer of our translation files to and from our translators.
🔗 Resource » Installation instructions for the Phrase Strings CLI depend on your platform (Windows, macOS, Linux). Just follow the CLI installation docs and you should be good to go.
CLI installed, let’s use it to connect our React project to Phrase Strings. From our React project root, let’s run the following command from the command line.
phrase init
Code language: Bash (bash)
We’ll be asked to provide the access token we generated in Step 5 above. Let’s paste it in.
We’ll then be given some prompts:
Select project
—Select the Phrase Strings project we created above.Select the format to use for language files you download from Phrase Strings
—Hit Enter to select the project’s default.Enter the path to the language file you want to upload to Phrase
—Enter./src/lang/en-US.json
, since that’s our source translation file.Enter the path to which to download language files from Phrase
—Enter./src/lang/<locale_name>.json
. (<locale_name>
is a placeholder here, and it allows us to download all the translation files for our project:en-US
,ar-EG
, etc.).Do you want to upload your locales now for the first time?
—Hit Enter to accept and upload.
🗒️ Note » A .phrase.yml
is created in our project to save the config we created above. It will be used by default when we run commands like phrase push
or phrase pull
in our project.
At this point, our en-US.json
file will get uploaded to Phrase, where our translators can use the powerful Phrase web admin to translate our app.
When our translators are finished, we can run the following command to pull all of their work into our project.
phrase pull
Code language: Bash (bash)
Our en-US.json
and ar-EG.json
files should now have the latest updates from our translators. We can simply run our app as normal to test our updated translations. Whether you have two locales, or two hundred, you only need to run two commands to keep your translations in sync. And your translators get an excellent environment to work in.
Adding a new language
Let’s demonstrate how much easier our lives are now with this new workflow. We’ll add French (fr-FR
) to our app. Here are the steps:
- Tell our translators we want to add
fr-FR
to our app. (They’ll manage all that in Phrase Strings). - Add French in our
locales
config object (seei18n-config.js
listing below). - Pull the new French translators from Phrase using
phrase pull
.
That’s literally it!
Here’s the updated i18n-config.js
:
// src/i18n/i18n-config.js
export const defaultLocale = "en-US";
export const locales = {
"en-US": {
name: "English",
dir: "ltr",
},
"ar-EG": {
name: "Arabic (العربية)",
dir: "rtl",
},
+ "fr-FR": {
+ name: "French (Français)",
+ dir: "ltr",
+ },
};
Code language: Diff (diff)
And here’s our app in French.
🔗 Resource » The app with French added is in the i18n-demo-extraction directory of our GitHub repo.
How do I localize my React TypeScript app with react-intl?
Using react-intl with TypeScript in a React app is relatively straightforward. An in-depth guide for this is outside the scope of this tutorial. We’ll cover strongly typing message IDs and we’ll share our complete solution code for your perusal.
In fact, most of react-intl will work with TypeScript out of the box. If you want to make your message IDs typed only to your keys, you can add the following code somewhere in your project.
// Your path may change here. Import the source
// translation file.
import messages from "./src/lang/en-US.json";
declare global {
namespace FormatjsIntl {
interface Message {
ids: keyof typeof messages;
}
}
}
Code language: TypeScript (typescript)
That’s basically it for react-intl TypeScript integration. Most of our other TypeScript considerations are specific to our demo.
🤿 Go deeper » You can find a diff of our TypeScript i18n solution in our GitHub repo. The entire TypeScript i18n demo is in an i18n-demo-typescript folder in the repo as well.
✋ Heads up » We used TypeScript 4.9.5, since TypeScript 5 was giving us peer dependency errors when we tried installing it in our project. (--force
ing the version 5 install seemed to work fine, but use at your own risk).
🔗 Resource » If you’re working with TypeScript and want to use the extraction/auto-generated ID workflow (optionally with Phrase), check out this diff. An entire Typescript extraction demo is in a dedicated i18n-demo-typescript-extraction folder.
Take React localization to the next level
We hope you found this guide to localizing React apps with react-intl/FormatJS enjoyable and informative. If you’re interested in localizing a Next.js app, check out our tutorial on Next.js internationalization. If you prefer using i18next, our guide to React localization with i18next should have you covered.
When you’re ready to start translating, let Phrase Strings take care of the hard work. With plenty of tools to automate your translation process and native integrations with platforms like GitHub, GitLab, and Bitbucket, Phrase Strings makes it simple for translators to pick up your content and manage it in its user-friendly string editor.
Once your translations are ready, you can easily pull them back into your project with a single command—or automatically—so you can stay focused on the code you love. Sign up for a free trial and see for yourself why developers love using Phrase Strings for software localization.
Speak with an expert
Want to learn how our solutions can help you unlock global opportunity? We’d be happy to show you around the Phrase Localization Platform and answer any questions you may have.