Internationalizing React Apps
- By Yury Dymov
- January 19th, 2017
- React
- 18 Comments
First of all, let’s define some vocabulary. “Internationalization” is a long word, and there are at least two widely used abbreviations: “intl,” “i18n”. “Localization” can be shortened to “l10n”.
Internationalization can be generally broken down into the following challenges:
- detecting the user’s locale;
- translating UI elements, titles and hints;
- serving locale-specific content such as dates, currencies and numbers.
Note: In this article, I am going to focus only on front-end part. We’ll develop a simple universal React application with full internationalization support.
Let’s use my boilerplate repository1 as a starting point. Here we have the Express web server for server-side rendering, webpack for building client-side JavaScript, Babel for translating modern JavaScript to ES5, and React for the UI implementation. We’ll use better-npm-run to write OS-agnostic scripts, nodemon to run a web server in the development environment and webpack-dev-server to serve assets.
Our entry point to the server application is server.js
. Here, we are loading Babel and babel-polyfill to write the rest of the server code in modern JavaScript. Server-side business logic is implemented in src/server.jsx
. Here, we are setting up an Express web server, which is listening to port 3001
. For rendering, we are using a very simple component from components/App.jsx
, which is also a universal application part entry point.
Our entry point to the client-side JavaScript is src/client.jsx
. Here, we mount the root component component/App.jsx
to the placeholder react-view
in the HTML markup provided by the Express web server.
So, clone the repository, run npm install
and execute nodemon and webpack-dev-server in two console tabs simultaneously.
In the first console tab:
git clone https://github.com/yury-dymov/smashing-react-i18n.git cd smashing-react-i18n npm install npm run nodemon
And in the second console tab:
cd smashing-react-i18n npm run webpack-devserver
A website should become available at localhost:3001
2. Open your favorite browser and try it out.
We are ready to roll!
1. Detecting The User’s Locale Link
There are two possible solutions to this requirement. For some reason, most popular websites, including Skype’s and the NBA’s, use Geo IP to find the user’s location and, based on that, to guess the user’s language. This approach is not only expensive in terms of implementation, but also not really accurate. Nowadays, people travel a lot, which means that a location doesn’t necessarily represent the user’s desired locale. Instead, we’ll use the second solution and process the HTTP header Accept-Language
on the server side and extract the user’s language preferences based on their system’s language settings. This header is sent by every modern browser within a page request.
Accept-Language Request Header Link
The Accept-Language
request header provides the set of natural languages that are preferred as a response to the request. Each language range may be given an associated “quality” value, which represents an estimate of the user’s preference for the languages specified by that range. The quality value defaults to q=1
. For example, Accept-Language: da, en-gb;q=0.8, en;q=0.7
would mean, “I prefer Danish, but will accept British English and other types of English.” A language range matches a language tag if it exactly equals the tag or if it exactly equals a prefix of the tag such that the first tag character following the prefix is -
.
(It is worth mentioning that this method is still imperfect. For example, a user might visit your website from an Internet cafe or a public computer. To resolve this, always implement a widget with which the user can change the language intuitively and that they can easily locate within a few seconds.)
Implementing Detection of User’s Locale Link
Here is a code example for a Node.js Express web server. We are using the accept-language
package, which extracts locales from HTTP headers and finds the most relevant among the ones supported by your website. If none are found, then you’d fall back to the website’s default locale. For returning users, we will check the cookie’s value instead.
Let’s start by installing the packages:
npm install --save accept-language npm install --save cookie-parser js-cookie
And in src/server.jsx
, we’d have this:
import cookieParser from 'cookie-parser'; import acceptLanguage from 'accept-language'; acceptLanguage.languages(['en', 'ru']); const app = express(); app.use(cookieParser()); function detectLocale(req) { const cookieLocale = req.cookies.locale; return acceptLanguage.get(cookieLocale || req.headers['accept-language']) || 'en'; } … app.use((req, res) => { const locale = detectLocale(req); const componentHTML = ReactDom.renderToString(<App />); res.cookie('locale', locale, { maxAge: (new Date() * 0.001) + (365 * 24 * 3600) }); return res.end(renderHTML(componentHTML)); });
Here, we are importing the accept-language
package and setting up English and Russian locales as supported. We are also implementing the detectLocale
function, which fetches a locale value from a cookie; if none is found, then the HTTP Accept-Language
header is processed. Finally, we are falling back to the default locale (en
in our example). After the request is processed, we add the HTTP header Set-Cookie
for the locale detected in the response. This value will be used for all subsequent requests.
2. Translating UI Elements, Titles And Hints Link
I am going to use the React Intl3 package for this task. It is the most popular and battle-tested i18n implementation of React apps. However, all libraries use the same approach: They provide “higher-order components” (from the functional programming design pattern4, widely used in React), which injects internationalization functions for handling messages, dates, numbers and currencies via React’s context features.
First, we have to set up the internationalization provider. To do so, we will slightly change the src/server.jsx
and src/client.jsx
files.
npm install --save react-intl
Here is src/server.jsx
:
import { IntlProvider } from 'react-intl'; … --- const componentHTML = ReactDom.renderToString(<App />); const componentHTML = ReactDom.renderToString( <IntlProvider locale={locale}> <App /> </IntlProvider> ); …
And here is src/client.jsx
:
import { IntlProvider } from 'react-intl'; import Cookie from 'js-cookie'; const locale = Cookie.get('locale') || 'en'; … --- ReactDOM.render(<App />, document.getElementById('react-view')); ReactDOM.render( <IntlProvider locale={locale}> <App /> </IntlProvider>, document.getElementById('react-view') );
So, now all IntlProvider
child components will have access to internationalization functions. Let’s add some translated text to our application and a button to change the locale (for testing purposes). We have two options: either the FormattedMessage
component or the formatMessage
function. The difference is that the component will be wrapped in a span
tag, which is fine for text but not suitable for HTML attribute values such as alt
and title
. Let’s try them both!
Here is our src/components/App.jsx
file:
import { FormattedMessage } from 'react-intl'; … --- <h1>Hello World!</h1> <h1><FormattedMessage defaultMessage="Hello World!" description="Hello world header greeting" /></h1>
Please note that the id
attribute should be unique for the whole application, so it makes sense to develop some rules for naming your messages. I prefer to follow the format componentName.someUniqueIdWithInComponent
. The defaultMessage
value will be used for your application’s default locale, and the description
attribute gives some context to the translator.
Restart nodemon and refresh the page in your browser. You should still see the “Hello World” message. But if you open the page in the developer tools, you will see that text is now inside the span
tags. In this case, it isn’t an issue, but sometimes we would prefer to get just the text, without any additional tags. To do so, we need direct access to the internationalization object provided by React Intl.
Let’s go back to src/components/App.jsx
:
--- import { FormattedMessage } from 'react-intl'; import { FormattedMessage, intlShape, injectIntl, defineMessages } from 'react-intl'; const propTypes = { intl: intlShape.isRequired, }; const messages = defineMessages({ helloWorld2: { id: 'app.hello_world2', defaultMessage: 'Hello World 2!', }, }); --- export default class extends Component { class App extends Component { render() { return ( <div className="App"> <h1> <FormattedMessage defaultMessage="Hello World!" description="Hello world header greeting" /> </h1> <h1>{this.props.intl.formatMessage(messages.helloWorld2)}</h1> </div> ); } } App.propTypes = propTypes; export default injectIntl(App);
We’ve had to write a lot more code. First, we had to use injectIntl
, which wraps our app component and injects the intl
object. To get the translated message, we had to call the formatMessage
method and pass a message
object as a parameter. This message
object must have unique id
and defaultValue
attributes. We use defineMessages
from React Intl to define such objects.
The best thing about React Intl is its ecosystem. Let’s add babel-plugin-react-intl to our project, which will extract FormattedMessages
from our components and build a translation dictionary. We will pass this dictionary to the translators, who won’t need any programming skills to do their job.
npm install --save-dev babel-plugin-react-intl
Here is .babelrc
:
{ "presets": [ "es2015", "react", "stage-0" ], "env": { "development": { "plugins":[ ["react-intl", { "messagesDir": "./build/messages/" }] ] } } }
Restart nodemon and you should see that a build/messages
folder has been created in the project’s root, with some folders and files inside that mirror your JavaScript project’s directory structure. We need to merge all of these files into one JSON. Feel free to use my script5. Save it as scripts/translate.js
.
Now, we need to add a new script to package.json
:
"scripts": { … "build:langs": "babel scripts/translate.js | node", … }
Let’s try it out!
npm run build:langs
You should see an en.json
file in the build/lang
folder with the following content:
{ "app.hello_world": "Hello World!", "app.hello_world2": "Hello World 2!" }
It works! Now comes interesting part. On the server side, we can load all translations into memory and serve each request accordingly. However, for the client side, this approach is not applicable. Instead, we will send the JSON file with translations once, and a client will automatically apply the provided text for all of our components, so the client gets only what it needs.
Let’s copy the output to the public/assets
folder and also provide some translation.
ln -s ../../build/lang/en.json public/assets/en.json
Note: If you are a Windows user, symlinks are not available to you, which means you have to manually copy the command below every time you rebuild your translations:
cp ../../build/lang/en.json public/assets/en.json
In public/assets/ru.json
, we need the following:
{ "app.hello_world": "Привет мир!", "app.hello_world2": "Привет мир 2!" }
Now we need to adjust the server and client code.
For the server side, our src/server.jsx
file should look like this:
--- import { IntlProvider } from 'react-intl'; import { addLocaleData, IntlProvider } from 'react-intl'; import fs from 'fs'; import path from 'path'; import en from 'react-intl/locale-data/en'; import ru from 'react-intl/locale-data/ru'; addLocaleData([…ru, …en]); const messages = {}; const localeData = {}; ['en', 'ru'].forEach((locale) => { localeData[locale] = fs.readFileSync(path.join(__dirname, `../node_modules/react-intl/locale-data/${locale}.js`)).toString(); messages[locale] = require(`../public/assets/${locale}.json`); }); --- function renderHTML(componentHTML) { function renderHTML(componentHTML, locale) { … <script type="application/javascript" src="${assetUrl}/public/assets/bundle.js"></script> <script type="application/javascript">${localeData[locale]}</script> … --- <IntlProvider locale={locale}> <IntlProvider locale={locale} messages={messages[locale]}> … --- return res.end(renderHTML(componentHTML)); return res.end(renderHTML(componentHTML, locale)); ```
Here we are doing the following:
- caching messages and locale-specific JavaScript for the currency,
DateTime
andNumber
formatting during startup (to ensure good performance); - extending the
renderHTML
method so that we can insert locale-specific JavaScript into the generated HTML markup; - providing the translated messages to
IntlProvider
(all of those messages are now available to child components).
For the client side, first we need to install a library to perform AJAX requests. I prefer to use isomorphic-fetch because we will very likely also need to request data from third-party APIs, and isomorphic-fetch can do that very well in both client and server environments.
npm install --save isomorphic-fetch
Here is src/client.jsx
:
--- import { IntlProvider } from 'react-intl'; import { addLocaleData, IntlProvider } from 'react-intl'; import fetch from 'isomorphic-fetch'; const locale = Cookie.get('locale') || 'en'; fetch(`/public/assets/${locale}.json`) .then((res) => { if (res.status >= 400) { throw new Error('Bad response from server'); } return res.json(); }) .then((localeData) => { addLocaleData(window.ReactIntlLocaleData[locale]); ReactDOM.render( --- <IntlProvider locale={locale}> <IntlProvider locale={locale} messages={localeData}> … ); }).catch((error) => { console.error(error); });
We also need to tweak src/server.jsx
, so that Express serves the translation JSON files for us. Note that in production, you would use something like nginx
instead.
app.use(cookieParser()); app.use('/public/assets', express.static('public/assets'));
After the JavaScript is initialized, client.jsx
will grab the locale from the cookie and request the JSON file with the translations. Afterwards, our single-page application will work as before.
Time to check that everything works fine in the browser. Open the “Network” tab in the developer tools, and check that JSON has been successfully fetched by our client.
To finish this part, let’s add a simple widget to change the locale, in src/components/LocaleButton.jsx
:
import React, { Component, PropTypes } from 'react'; import Cookie from 'js-cookie'; const propTypes = { locale: PropTypes.string.isRequired, }; class LocaleButton extends Component { constructor() { super(); this.handleClick = this.handleClick.bind(this); } handleClick() { Cookie.set('locale', this.props.locale === 'en' ? 'ru' : 'en'); window.location.reload(); } render() { return <button onClick={this.handleClick}>{this.props.locale === 'en' ? 'Russian' : 'English'}; } } LocaleButton.propTypes = propTypes; export default LocaleButton;
Add the following to src/components/App.jsx
:
import LocaleButton from './LocaleButton'; … <h1>{this.props.intl.formatMessage(messages.helloWorld2)}</h1> <LocaleButton locale={this.props.intl.locale} />
Note that once the user changes their locale, we’ll reload the page to ensure that the new JSON file with the translations is fetched.
High time to test! OK, so we’ve learned how to detect the user’s locale and how to show translated messages. Before moving to the last part, let’s discuss two other important topics.
Pluralization And Templates Link
In English, most words take one of two possible forms: “one apple,” “many apples.” In other languages, things are a lot more complicated. For example, Russian has four different forms. Hopefully, React Intl will help us to handle pluralization accordingly. It also supports templates, so you can provide variables that will be inserted into the template during rendering. Here’s how it works.
In src/components/App.jsx
, we have the following:
const messages = defineMessages({ counting: { id: 'app.counting', defaultMessage: 'I need to buy {count, number} {count, plural, one {apple} other {apples}}' }, … <LocaleButton locale={this.props.intl.locale} /> <div>{this.props.intl.formatMessage(messages.counting, { count: 1 })}</div> <div>{this.props.intl.formatMessage(messages.counting, { count: 2 })}</div> <div>{this.props.intl.formatMessage(messages.counting, { count: 5 })}</div>
Here, we are defining a template with the variable count
. We will print either “1 apple” if count
is equal to 1, 21
, etc. or “2 apples” otherwise. We have to pass all variables within formatMessage
‘s values
option.
Let’s rebuild our translation file and add the Russian translations to check that we can provide more than two variants for languages other than English.
npm run build:langs
Here is our public/assets/ru.json
file:
{ … "app.counting": "Мне нужно купить {count, number} {count, plural, one {яблоко} few {яблока} many {яблок}}" }
All use cases are covered now. Let’s move forward!
3. Serving Locale-Specific Content Such As Dates, Currencies And Numbers Link
Your data will be represented differently depending on the locale. For example, Russian would show 500,00 $
and 10.12.2016
, whereas US English would show $500.00
and 12/10/2016
.
React Intl provides React components for such kinds of data and also for the relative rendering of time, which will automatically be updated each 10 seconds if you do not override the default value.
Add this to src/components/App.jsx
:
--- import { FormattedMessage, intlShape, injectIntl, defineMessages } from 'react-intl'; import { FormattedDate, FormattedRelative, FormattedNumber, FormattedMessage, intlShape, injectIntl, defineMessages, } from 'react-intl'; … <div>{this.props.intl.formatMessage(messages.counting, { count: 5 })}</div> <div><FormattedDate value={Date.now()} /></div> <div><FormattedNumber value="1000" currency="USD" currencyDisplay="symbol" /></div> <div><FormattedRelative value={Date.now()} /></div>
Refresh the browser and check the page. You’ll need to wait for 10 seconds to see that the FormattedRelative
component has been updated.
You’ll find a lot more examples in the official wiki8.
Cool, right? Well, now we might face another problem, which affects universal rendering.
On average, two seconds will elapse between when the server provides markup to the client and the client initializes client-side JavaScript. This means that all DateTimes
rendered on the page might have different values on the server and client sides, which, by definition, breaks universal rendering. To resolve this, React Intl provides a special attribute, initialNow
. This provides a server timestamp that will initially be used by client-side JavaScript as a timestamp; this way, the server and client checksums will be equal. After all components have been mounted, they will use the browser’s current timestamp, and everything will work properly. So, this trick is used only to initialize client-side JavaScript, in order to preserve universal rendering.
Here is src/server.jsx
:
--- function renderHTML(componentHTML, locale) { function renderHTML(componentHTML, locale, initialNow) { return ` <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Hello React</title> </head> <body> <div>${componentHTML}</div> <script type="application/javascript" src="${assetUrl}/public/assets/bundle.js"></script> <script type="application/javascript">${localeData[locale]}</script> <script type="application/javascript">window.INITIAL_NOW=${JSON.stringify(initialNow)}</script> </body> </html> `; } const initialNow = Date.now(); const componentHTML = ReactDom.renderToString( --- <IntlProvider locale={locale} messages={messages[locale]}> <IntlProvider initialNow={initialNow} locale={locale} messages={messages[locale]}> <App /> </IntlProvider> ); res.cookie('locale', locale, { maxAge: (new Date() * 0.001) + (365 * 24 * 3600) }); --- return res.end(renderHTML(componentHTML, locale)); return res.end(renderHTML(componentHTML, locale, initialNow));
And here is src/client.jsx
:
--- <IntlProvider locale={locale} messages={localeData}> <IntlProvider initialNow={parseInt(window.INITIAL_NOW, 10)} locale={locale} messages={localeData}>
Restart nodemon, and the issue will almost be gone! It might persist because we are using Date.now()
, instead of some timestamp provided by the database. To make the example more realistic, in app.jsx
replace Date.now()
with a recent timestamp, like 1480187019228
.
(You might face another issue when the server is not able to render the DateTime
in the proper format, which will also break universal rendering. This is because version 4 of Node.js is not built with Intl support by default. To resolve this, follow one of the solutions described in the official wiki11.)
4. A Problem Link
It sounds too good to be true so far, doesn’t it? We as front-end developers always have to be very cautious about anything, given the variety of browsers and platforms. React Intl uses the native Intl browser API for handling the DateTime
and Number
formats. Despite the fact that it was introduced in 2012, it is still not supported by all modern browsers. Even Safari supports it partially only since iOS 10. Here is the whole table from CanIUse for reference.
This means that if you are willing to cover a minority of browsers that don’t support the Intl API natively, then you’ll need a polyfill. Thankfully, there is one, Intl.js14. It might sound like a perfect solution once again, but from my experience, it has its own drawbacks. First of all, you’ll need to add it to the JavaScript bundle, and it is quite heavy. You’ll also want to deliver the polyfill only to browsers that don’t support the Intl API natively, to reduce your bundle size. All of these techniques are well known, and you might find them, along with how to do it with webpack, in Intl.js’ documentation15. However, the biggest issue is that Intl.js is not 100% accurate, which means that the DataTime
and Number
representations might differ between the server and client, which will break server-side rendering once again. Please refer to the relevant GitHub issue16 for more details.
I’ve come up with another solution, which certainly has its own drawbacks, but it works fine for me. I implemented a very shallow polyfill17, which has only one piece of functionality. While it is certainly unusable for many cases, it adds only 2 KB to the bundle’s size, so there is not even any need to implement dynamic code-loading for outdated browsers, which makes the overall solution simpler. Feel free to fork and extend it if you think this approach would work for you.
Conclusion Link
Well, now you might feel that things are becoming too complicated, and you might be tempted to implement everything yourself. I did that once; I wouldn’t recommend it. Eventually, you will arrive at the same ideas behind React Intl’s implementation, or, worse, you might think there are not many options to make certain things better or to do things differently. You might think you can solve the Intl API support issue by relying on Moment.js18 instead (I won’t mention other libraries with the same functionality because they are either unsupported or unusable). Fortunately, I tried that, so I can save you a lot of time. I’ve learned that Moment.js is a monolith and very heavy, so while it might work for some folks, I wouldn’t recommend it. Developing your own polyfill doesn’t sound great because you will surely have to fight with bugs and support the solution for quite some time. The bottom line is that there is no perfect solution at the moment, so choose the one that suits you best.
(If you feel lost at some point or something doesn’t work as expected, check the “solution” branch of my repository19.)
Hopefully, this article has given you all of the knowledge needed to build an internationalized React front-end application. You should now know how to detect the user’s locale, save it in the cookie, let the user change their locale, translate the user interface, and render currencies, DateTimes
and Number
s in the appropriate formats! You should also now be aware of some traps and issues you might face, so choose the option that fits your requirements, bundle-size budget and number of languages to support.
(rb, al, il, vf)
Footnotes Link
- 1 https://github.com/yury-dymov/smashing-react-i18n
- 2 http://localhost:3001
- 3 https://github.com/yahoo/react-intl
- 4 https://medium.com/@franleplant/react-higher-order-components-in-depth-cf9032ee6c3e#.zb3t19yyx
- 5 https://github.com/yury-dymov/smashing-react-i18n/blob/solution/scripts/translate.js
- 6 https://www.smashingmagazine.com/wp-content/uploads/2017/01/AJAX-request-large-opt.png
- 7 https://www.smashingmagazine.com/wp-content/uploads/2017/01/AJAX-request-large-opt.png
- 8 https://github.com/yahoo/react-intl/wiki/Components
- 9 https://www.smashingmagazine.com/wp-content/uploads/2017/01/universal-rendering-is-broken-large-opt.png
- 10 https://www.smashingmagazine.com/wp-content/uploads/2017/01/universal-rendering-is-broken-large-opt.png
- 11 https://github.com/nodejs/node/wiki/Intl
- 12 https://www.smashingmagazine.com/wp-content/uploads/2017/01/intl-browser-support-large-opt.png
- 13 https://www.smashingmagazine.com/wp-content/uploads/2017/01/intl-browser-support-large-opt.png
- 14 https://github.com/andyearnshaw/Intl.js
- 15 https://github.com/andyearnshaw/Intl.js/
- 16 https://github.com/andyearnshaw/Intl.js/issues/124
- 17 https://github.com/yury-dymov/intl-polyfill
- 18 http://momentjs.com/
- 19 https://github.com/yury-dymov/smashing-react-i18n/tree/solution