Software localization
React Redux Tutorial: Internationalization with react-i18n-redux

As our React apps get bigger, we often want a robust way to manage our app state, and Redux is one of the most popular choices for state management. Not only does it offer simple building blocks for sharing state through a store, reducers, and actions, Redux also provides some of the best development tools and ecosystems around. And when it comes to internationalizing our React apps, we sometimes want to forgo the more common i18n solutions and to marry our i18n to our app's state. There are pros and cons to this, of course, but this article won't go into them. I'm assuming here that you've made up your mind and that you want a Redux-based i18n solution for your React app. In this tutorial, we'll start with a demo app that is not internationalized yet, and we'll internationalize and localize it with one of the most popular Redux-based i18n libraries, react-redux-i18n.
🔗 Resource » Learn everything you need to make your JS applications accessible to international users with our Ultimate Guide to JavaScript Localization.
What is react-redux-i18n
Created by Artis Avotins, react-redux-i18n is a wrapper around the react-i18nify library that stores the i18n state in Redux. Using react-redux-i18n is basically like using react-i18nify, except that you have to wire up the library to your Redux store (which we'll get into a bit later here). The libraries are fairly straightforward to use, so why don't we jump straight into working with them?
✋🏽 Heads Up » Because react-redux-i18n is largely a wrapper around react-i18nify, we'll often use the two names interchangeably in this article.
The Demo App
First, let's cover our starter project: a little demo app called Showdux. Showdux is a web app made with React and Redux that lists upcoming concerts and provides a forum for people to chat about them. Here's what it looks like before i18n.
Our app before internationalization
The app is a simple bread-and-butter React/Redux app where you can:
- Browse through concerts, flipping through them one at a time with the previous/next arrow buttons.
- Comment on each concert.
To allow this, we have the following pieces of state in our Redux store:
- The list of concerts that are loaded from a JSON file on the network and then populated into an array in the Redux store.
- The list of comments that are also loaded from JSON files (one per concert), and populated into a map keyed by concert ID.
- The ID of the currently active concert, which is used to display the concert's info and its comments.
Packages We're Using in the Demo App
We've built our starter demo with the following libraries (with versions at the time of writing):
- React (16.12)
- Redux (7.1)
- React Redux (7.1)
- prop-types (15.7)
- React-bulma-components (3.1.3) — these are React components for the Bulma CSS framework, and are used for styling
- node-sass (4.13) — we use this to customize Bulma and add our own custom styles
The App Code
Feel free to download and browse the starter app's code. You can get it on GitHub. You might enjoy downloading the starter app and coding along with us as we internationalize it here. Alternatively, you can just use the following sections as a cookbook and incorporate them into your own app. Either way, let's i18n.
Internationalizing Our App with react-redux-i18n
Now that we've looked at our starter app, let's get to internationalizing it. In the next several sections, we'll cover everything from installing our needed packages to displaying translated messages.
Installation
Installing react-redux-i18n into an existing Redux app isn't too complex. It does involve a few steps.
1. Install NPM Packages
From the command line, we can install the react-redux-i18n package and its peer dependency, redux-thunk.
# with NPM npm install --save react-redux-i18n@1.9.3 redux-thunk@2.3.0 # or, with Yarn yarn add react-redux-i18n@1.9.3 redux-thunk@2.3.0
2. Add the i18n Reducer to our Root Reducer
With the NPM packages in place, we can add react-redux-i18n's i18nReducer
to our root reducer by including it in our combineReducer()
call.
import { combineReducers } from "redux"; import { i18nReducer } from "react-redux-i18n"; import comments from "./comments"; import concerts from "./concerts"; const rootReducer = combineReducers({ i18n: i18nReducer, concerts, comments }); export default rootReducer;
🗒 Note » According to the official documentation, the library's reducer must be called
i18n
. So we need to make sure that we don't have a reducer calledi18n
that clashes with it.
3. Add the Initial Translations Object
To get started, we can have a simple translations object that includes a message per-locale.
const translations = { en: { concerts: { title: "Shows", }, }, ar: { concerts: { title: "الحفلات", }, }, }; export default translations;
4. Wire Up Redux Thunk and Translations, and Call Initializers
Next, we'll want to add Redux Thunk as middleware when we create our store. We'll also want to call a few initializer functions and actions that react-redux-i18n provides. Note that the actions load in our translations and set the initial locale.
import thunk from "redux-thunk"; import { createStore, applyMiddleware } from "redux"; import { setLocale, loadTranslations, syncTranslationWithStore, } from "react-redux-i18n"; import rootReducer from "./reducers/index"; import translations from "../l10n/translations"; const store = createStore(rootReducer, applyMiddleware(thunk)); syncTranslationWithStore(store); store.dispatch(loadTranslations(translations)); store.dispatch(setLocale("en")); export default store;
5. (Re)wire Redux DevTools (Optional)
If you love the Redux DevTools browser extensions as much as I do, you'll want to connect them (back) into your store for that oh-so-wonderful debugging they provide. We have to compose the DevTools as middleware along with Redux Thunk.
import thunk from "redux-thunk"; import { createStore, applyMiddleware, compose } from "redux"; import { setLocale, loadTranslations, syncTranslationWithStore, } from "react-redux-i18n"; import rootReducer from "./reducers/index"; import translations from "../l10n/translations"; const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const store = createStore( rootReducer, composeEnhancers(applyMiddleware(thunk)), ); syncTranslationWithStore(store); store.dispatch(loadTranslations(translations)); store.dispatch(setLocale("en")); export default store;
That's it. react-redux-i18n should be installed in our project now. If all went well, we should see the following state in our store when we look at our Redux DevTools panel.
Our Redux state after installing react-redux-i18n
Getting the Active Locale
react-redux-i18n stores the current locale as a piece of state in our Redux store. So retrieving the current locale is as easy as retrieving any other bit of Redux state.
import { connect } from "react-redux"; import React, { useEffect } from "react"; import { PropTypes } from "prop-types"; import AppBar from "./Layout/AppBar"; import AppFooter from "./Layout/AppFooter"; import ConcertList from "./Concerts/ConcertList"; function App(props) { useEffect(() => { document.dir = props.locale === "ar" ? "rtl" : "ltr"; }, [props.locale]); return ( <> <AppBar /> <ConcertList /> <AppFooter /> </> ); } App.propTypes = { locale: PropTypes.string.isRequired, }; const mapStateToProps = state => ({ locale: state.i18n.locale }); export default connect(mapStateToProps)(App);
We use our good old mapStateToProps
and connect the locale to our component with the familiar connect
function that react-redux provides. Here, we're using the current locale to set the document's layout direction.
🗒 Note » We're using React hooks to change the document direction whenever the locale changes. We could have easily used a combination of
componentDidMount
andcomponentDidUpdate
to achieve the same thing with class-based components.
Getting the Active Layout Direction Using a Redux Selector
In the code above, we inferred the layout direction based on the current locale. We may want to know the layout direction in multiple places in our app, so it could be wise to have the logic that determines the layout direction in one place. A very simple Redux selector can help with this.
export function getDir(state) { return state.i18n.locale === "ar" ? "rtl" : "ltr"; }
We could then pull in this selector whenever one of our components needed to know the layout direction. Here's a quick refactor of the App
component above that shows this.
import { connect } from "react-redux"; import React, { useEffect } from "react"; import { PropTypes } from "prop-types"; import AppBar from "./Layout/AppBar"; import AppFooter from "./Layout/AppFooter"; import ConcertList from "./Concerts/ConcertList"; import { getDir } from "../redux/selectors/i18n"; function App(props) { useEffect(() => { document.dir = props.dir; }, [props.dir]); return ( <> <AppBar /> <ConcertList /> <AppFooter /> </> ); } App.propTypes = { dir: PropTypes.string.isRequired, }; const mapStateToProps = state => ({ dir: getDir(state) }); export default connect(mapStateToProps)(App);
Manually Setting the Active Locale
As seen earlier, react-redux-i18n provides a setLocale()
Redux action that can be used to set the locale.
// ... store.dispatch(setLocale("en")); // ...
In our components, we can import this action, connect it via mapDispatchToProps
, and call it to set the locale manually. However, we may want to wrap the action to provide a fallback locale.
Fallback: Handling Unsupported Locales
react-redux-i18n doesn't have a notion of a fallback locale, at least at the time of writing. A fallback locale is basically a default to "fall back" to in case we're trying to set a locale that our app doesn't support. We can implement a simple fallback system ourselves. First, let's set up some light configuration for our app.
export const supportedLocales = { en: "English", ar: "عربي", }; export const fallbackLocale = "en";
With a single source of truth for our i18n config, we can wrap react-redux-i18n's setLocale
in our own action, one that handles fallback behavior.
import { setLocale } from "react-redux-i18n"; import { supportedLocales, fallbackLocale } from "../../config/i18n"; export function setLocaleWithFallback(desiredLocale) { const finalLocale = Object.keys(supportedLocales).includes(desiredLocale) ? desiredLocale : fallbackLocale; return dispatch => dispatch(setLocale(finalLocale)); }
We're using Redux Thunk to get access to the dispatch
function. When our setLocaleWithFallback
action returns a function, the Thunk middleware will intercept it and provide access to dispatch
for us. We can now use setLocaleWithFallback
to manually set our locale safely. Let's use this action to get our app's language switcher working.
Building a Language Switcher
We start with our UI, a simple presentational LanguageSwitcher
React component.
import React from "react"; import Icon from "react-bulma-components/lib/components/icon"; import Navbar from "react-bulma-components/lib/components/navbar"; function LanguageSwitcher() { return ( <Navbar.Item dropdown hoverable href="#"> <Navbar.Link> <Icon> <span className="fas fa-globe" /> </Icon>{" "} <span>Language</span> </Navbar.Link> <Navbar.Dropdown> <Navbar.Item href="#">English</Navbar.Item> <Navbar.Item href="#">Arabic</Navbar.Item> </Navbar.Dropdown> </Navbar.Item> ); } export default LanguageSwitcher;
Our language switcher looks OK but doesn't do much yet
All we have to do to get our LanguageSwitcher
working is tie it up to our setLocaleWithFallback
action, so that clicking on a language link will trigger the Redux state change and cause the relevant components to re-render.
import React from "react"; import { connect } from "react-redux"; import { PropTypes } from "prop-types"; import Icon from "react-bulma-components/lib/components/icon"; import Navbar from "react-bulma-components/lib/components/navbar"; import { supportedLocales } from "../../config/i18n"; import { setLocaleWithFallback } from "../../redux/actions/i18n"; class LanguageSwitcher extends React.Component { handleLanguageLinkClick = (e, code) => { e.preventDefault(); this.props.setLocaleWithFallback(code); }; render() { return ( <Navbar.Item dropdown hoverable href="#"> <Navbar.Link> <Icon> <span className="fas fa-globe" /> </Icon>{" "} <span>Language</span> </Navbar.Link> <Navbar.Dropdown> {Object.keys(supportedLocales).map(code => ( <Navbar.Item href="#" key={code} active={code === this.props.locale} onClick={e => this.handleLanguageLinkClick(e, code)} > {supportedLocales[code]} </Navbar.Item> ))} </Navbar.Dropdown> </Navbar.Item> ); } } LanguageSwitcher.propTypes = { locale: PropTypes.string.isRequired, setLocaleWithFallback: PropTypes.func.isRequired, }; const mapStateToProps = state => ({ locale: state.i18n.locale }); const mapDispatchToProps = { setLocaleWithFallback }; export default connect(mapStateToProps, mapDispatchToProps)(LanguageSwitcher);
Of course, not much will happen when we switch languages at the moment. That's because we need to translate our UI with react-redux-i18n's components. Still, if we check our Redux Devtools, we can see that the i18n.locale
state is being updated. Let's get to translating our UI.
Working with Basic Translation Messages
react-redux-i18n is built on react-i18nify and uses the same Translate
React component for displaying translated messages. Of course, we need our translation messages to exist in the object we provide react-redux-i18n's loadTranslations
when we set it up (see Installation section above). We loaded ours from a simple translations.js
file.
const translations = { en: { concerts: { title: "Shows", }, }, ar: { concerts: { title: "الحفلات", }, }, }; export default translations;
In our components, we simply import the Translate
component and provide it the key to the message we want to display.
// ... import { Translate } from "react-redux-i18n"; // ... class ConcertListHeader extends Component { // ... renderTitle() { return ( <Heading> <Translate value="concerts.title" /> {" "} <Tag color="primary" size="large"> {this.props.concertCount} </Tag> </Heading> ); } // ... } // ...
That's all there is to it. Now when we switch our language/locale our concert header will automatically update to show the title in the currently active locale.
Our concert list title now updates to reflect the active locale
Interpolation
We can include dynamic content in our translation messages using the %{variableName}
syntax. Say we want to translate the subtitle on top of our comment list.
// ... renderHeader() { const comments = this.getCommentList(); return ( <Heading subtitle size={4} renderAs="h3"> {comments ? comments.length : 0} comments </Heading> ); } // ...
We're showing the number of the comments dynamically. Let's add translation messages that can accommodate that.
const translations = { en: { // ... comments: { title: "%{count} comments", }, }, ar: { // ... comments: { title: "%{count} تعليقات", }, }, }; export default translations;
We can now show these messages with the Translate
component.
// ... renderHeader() { const comments = this.getCommentList(); return ( <Heading subtitle size={4} renderAs="h3"> <Translate value="comments.title" count={comments ? comments.length : 0} /> </Heading> ); } // ...
We simply provide a prop to Translate
with the same name that we used in our translation messages with the %{...}
syntax, and we're good to go.
Plurals
While we've been able to display the number of comments dynamically in our translation messages, we haven't accounted for the plural forms of messages yet. For example, our English messages should read "0 comments", "1 comment", "22 comments", depending on the number of comments. Let's take care of that in our translation messages.
const translations = { en: { concerts: { title: "Shows", }, comments: { title: "%{count} comments", title_0: "%{count} comments", title_1: "%{count} comment", }, }, //... }; export default translations;
When we declare <Translate value="comments.title" count={1} />
, given the translation messages above, we'll get "1 comment"
. If we supply a count
of 0
, we'll get "0 comments"
, since the count
matches the title_0
form. If we supply a count
of 2
, 10
, or 10000
, we'll get the first title
form, without a number—this is the general catch-all form. The count
prop/param here is special, since react-redux-i18n will only use the count
param to determine which plural form it selects from. We can, of course, use count
as a dynamic value in our messages as well.
🗒 Note » Unlike other i18n libraries, react-redux-i18n does not use CLDR plural rules, or provide a way to specify ranges e.g. 3-10 for its plural forms. So for the range 3-10, we would need to provide the keys
title_3
,title_4
, ... ,title_10
individually.
Formatting Numbers
react-redux-i18n uses react-i18nify's <Localize>
component for formatting numbers.
import { Localize } from "react-redux-i18n"; // ... <Localize value={3} /> // When active locale is Arabic, yields, "٣", // which is "3" in Eastern Arabic numeral system, used in Arabic <Localize value={199.99} options={{ style: "currency", currency: "EUR" }} /> // When active locale is English, yields "€199.99" <Localize value={0.5} options={{ style: "percent" }} /> // When active locale is English, yields "50%" <Localize value={3.14159} options={{ minimumFractionDigits: 2, maximumFractionDigits: 3 }} /> // When active locale is English, yields "3.142"
We can use the options
prop to specify the display style of the number, and to have more granular control over its formatting. Localize
uses the standard JavaScript Intl.NumberFormat
under the hood, so any options
that can be passed to the NumberFormat
constructor will work here as well. Check out the MDN documentation on Intl.NumberFormat
for more details.
🗒 Note » At the time of writing, react-redux-i18n's underlying react-i18nify library seems to be using different formatting rules for numbers output by
<Translate>
and those output by<Localize>
.Localize
will respect the numeral system of the current locale, whereasTranslate
seems to always output numbers in the Western Arabic numeral system e.g.Translate
will ouput the number three as"3"
when the active locale is Arabic;Localize
will output it as"٣"
, which is more appropriate for Arabic.
Formatting Dates
The Localize
component is used for formatting localized dates as well as numbers. Let's use the component to format our dates per-locale. First, we need to specify date format strings, per the Moment.js specification, in our translation files.
const translations = { en: { date: { long: "MMMM Do, YYYY", short: "MMM D YYYY", }, // ... }, ar: { date: { long: "MMMM Do، YYYY", short: "MMM D، YYYY", }, // ... }, }; export default translations;
We can then use these formats in our components.
// ... import { Localize } from "react-redux-i18n"; function ConcertCard(props) { const { title, occursOn, description, imageUrl } = props.concert; return ( <Card> <Card.Content> <Media> <Media.Item> // ... <Heading subtitle size={6}> <Tag color="primary"> <Localize value={occursOn} dateFormat="date.long" /> </Tag> </Heading> </Media.Item> </Media> // ... </Card.Content> // ... </Card> ); } // ... export default ConcertCard;
//... import { Localize } from "react-redux-i18n"; function CommentCard(props) { const { profileImageUrl, userName, commentedOn, text } = props.comment; return ( <Box> <Media> // ... <Media.Item> <Content> <p> // ... <small> <Localize value={commentedOn} dateFormat="date.short" /> </small> //... </p> </Content> </Media.Item> </Media> </Box> ); } // ... export default CommentCard;
With these changes in place, our app now has localized dates.
Our dates are now formatted exactly how we want, per-locale
Just following the recipes above, and with a little CSS love, we achieve this:
Our fully internationalized/localized app
That's All Folks
We hope you enjoyed our rundown of react-redux-i18n and its features. If you want us to cover the library in more depth, or to cover any i18n topic at all, just let us know in the comments below. And if you're looking to take your i18n game to the next level, check out Phrase, the i18n platform by developers for developers. Phrase features a REST API, CLI tool, over 40 translation file formats, Github and Bitbucket sync, and much, much more. Check out all of Phrase's products, and sign up for a free 14-day trial.