Software localization

React Redux Tutorial: Internationalization with react-i18n-redux

Sometimes, we want our i18n state in Redux. This React Redux tutorial will show you how it’s done using the react-redux-i18n library.
Software localization blog category featured image | Phrase

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.

Showdux demo app before internationalization | Phrase

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):

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 called i18n 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. Redux DevTools panel | Phrase

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 and componentDidUpdate 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;

Language switcher | Phrase

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. Concert list title active locale update | Phrase

Our concert list title now updates to reflect the active locale

🔗 Resource » Check out all of ConcertListHeader's code in the app's Github repo.

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, whereas Translate 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;

🔗 Resource » This is just an excerpt. You see all of the ConcertCard code in our Github repo.

//...

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;

🔗 Resource » This is just an excerpt. You see all of the CommentCard code in our Github repo.

With these changes in place, our app now has localized dates. Demo app with localized dates | Phrase

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: Fully localized demo app | Phrase

Our fully internationalized/localized app

🔗 Resource » You can get the fully internationalized and localized app code from our Github repo.

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.