abidibo.net

Internationalization with react-redux-starter-kit

i18n react redux

This was the first time I had to manage serious translations for a react-redux project. I use to develop my applications starting from the awesome react-redux-starter-key by davezuko.

This starter kit doesn't include i18n support out of the box, so some work is needed. I followed this article from freecodcamp and changed things a bit, so let's start.

Install react-intl

It seems that in the react world there is one library to choose for internationalization: react-intl by yahoo.

Let's install it:

npm install --save react-intl

Also, let's install its babel plugin which does a very important job:

Extracts string messages from React components that use React Intl
npm install --save-dev babel-plugin-react-intl

Babel needs now some more configuration: open your ~/config/project.config.js and change the compiler_babel key as follows:

  compiler_babel : {
    cacheDirectory : true,
    plugins        : ['transform-runtime', [
      'react-intl', {
        'messagesDir': './i18n/messages',
        'enforceDescriptions': false
      }
    ]],
    presets        : ['es2015', 'react', 'stage-0']
  },

In line 5 we say that we want all the extracted strings to be inside a folder named i18n in the root of our react-redux-starter-kit project. The folder structure of the components will be cloned and every components will have its <ComponentName>.json file with the extracted strings. Wonderful!

This library provides many functionalities, but for our scope some are important at this point:

  • it provides a IntlProvider component which wraps the application and has the two relevant props: locale (the current locale) and messages (the current locale strings object):

    ReactDOM.render(
        <IntlProvider locale="en" messages={enMessages}>
            <App />
        </IntlProvider>,
        document.getElementById('container')
    );
    

    this is the example code the library provides you, we will need to do something different in order to integrate internationalization with redux;

  • It provides components and functions used to mark a string/currency/date as translatable, i.e:

    <FormattedMessage
      id="welcome"
      defaultMessage={`Hello {name}, you have {unreadCount, number} {unreadCount, plural,
        one {message}
        other {messages}
      }`}
      values={{name: {name}, unreadCount}}
    />
    

Load data and attach the i18n context to redux

We need to load the appropriate locale data for the languages we need to support, I compile for the browser, so I have to add manually all this data, because react-intl only comes with en data (in my example I'll support both english and italian).

Also I'd like to store the current locale settings inside the redux store, in order to have the UI updated when the locale changes. For that reason I need to connect the react-intl provider to redux.

In the react-intl examples the provided IntlProvider is used as a wrapper for the entire application, but in my case, I need it to be a child of the redux Provider (otherwise the store is not found, and I get something like https://github.com/reactjs/react-redux/issues/57).

For these reasons, a good place to load data and inject the IntlProvider is the ~/containers/AppContainer.js component:

import React, { Component, PropTypes } from 'react'
import { browserHistory, Router } from 'react-router'
import { connect, Provider } from 'react-redux'
import { IntlProvider, addLocaleData } from 'react-intl'

import en from 'react-intl/locale-data/en'
import it from 'react-intl/locale-data/it'

// ========================================================
// Internationalization
// ========================================================
addLocaleData([...en, ...it])
let mapStateToProps = (state) => { return { locale: state.i18n.locale, messages: state.i18n.messages } }
let ConnectedIntlProvider = connect(mapStateToProps)(IntlProvider)

class AppContainer extends Component {
  static propTypes = {
    routes : PropTypes.array.isRequired,
    store  : PropTypes.object.isRequired
  }

  shouldComponentUpdate () {
    return false
  }

  render () {
    const { routes, store } = this.props

    return (
      <Provider store={store}>
        <ConnectedIntlProvider>
          <div style={{ height: '100%' }}>
            <Router history={browserHistory} children={routes} />
          </div>
        </ConnectedIntlProvider>
      </Provider>
    )
  }
}

export default AppContainer

As you can see, the redux connection takes place in lines 13-14. The redux state objects mapped to props are i18n.locale and i18n.messages, so it's time to create a reducer for this stuff.

Let's create it: add ~/store/i18n.js

// Translated strings
import localeData from '../../i18n/locales/data.json'

// Define user's language. Different browsers have the user locale defined
// on different fields on the `navigator` object, so we make sure to account
// for these different by checking all of them
let defaultLanguage = (navigator.languages && navigator.languages[0]) ||
  navigator.language ||
  navigator.userLanguage

let langWithoutRegionCode = (language) => language.toLowerCase().split(/[_-]+/)[0]

// ------------------------------------
// Constants
// ------------------------------------
export const LOCALE_CHANGE = 'LOCALE_CHANGE'

// ------------------------------------
// Actions
// ------------------------------------
export function localeChange (locale) {
  return {
    type    : LOCALE_CHANGE,
    payload : locale
  }
}

// ------------------------------------
// Specialized Action Creator
// ------------------------------------
export const updateLocale = ({ dispatch }) => {
  return (nextLocale) => dispatch(localeChange(nextLocale))
}

// ------------------------------------
// Reducer
// ------------------------------------
const initialState = {
  locale: defaultLanguage,
  messages: localeData[defaultLanguage] || localeData[langWithoutRegionCode(defaultLanguage)] || localeData['en-en']
}

export default function i18nReducer (state = initialState, action) {
  return action.type === LOCALE_CHANGE
    ? {
      locale: action.payload,
      messages: localeData[action.payload] || localeData[langWithoutRegionCode(action.payload)] || localeData.en
    }
    : state
}

Let's examinate a bit this code:

  • line 2: we import the json containing all the translated strings, we'll se how to generate such json in a while;
  • line 7: we get the user current language, in order to set it as the default locale state;
  • line 21: we define the action used to change the current locale;
  • line 31: same as above, but it's a shortcut;
  • line 38: the initial state with the default locale and the default messages, taken from localData (line 2);
  • line 43: the reducer. When the locale changes, both the state locale and messages change.

Ok, now we need to add this reducer to the store, so edit the file ~/store/reducers.js and add the i18n reducer:

import { combineReducers } from 'redux'
import locationReducer from './location'
import i18nReducer from './i18n'

export const makeRootReducer = (asyncReducers) => {
  return combineReducers({
    location: locationReducer,
    i18n: i18nReducer,
    ...asyncReducers
  })
}

export const injectReducer = (store, { key, reducer }) => {
  if (Object.hasOwnProperty.call(store.asyncReducers, key)) return

  store.asyncReducers[key] = reducer
  store.replaceReducer(makeRootReducer(store.asyncReducers))
}

export default makeRootReducer

Build the translations file

We've seen that babel-plugin-react-intl will parse all the source code, and collect formatted messages in one json for each component. Now we need a way to collect all this strings and generate an unique file that can be sent to translators. We do this with a script (made by yahoo) that I've tweaked a bit in order to work properly inside our starter kit.

First of all I added a locale settings inside ~/config/project.config.js:

...
const config = {
  env : process.env.NODE_ENV || 'development',

  // ----------------------------------
  // Project Structure
  // ----------------------------------
  path_base  : path.resolve(__dirname, '..'),
  dir_client : 'src',
  dir_dist   : 'dist',
  dir_public : 'public',
  dir_server : 'server',
  dir_test   : 'tests',

  // ----------------------------------
  // Internationalization
  // ----------------------------------
  i18n: {
    // also add data in src/containers/AppContainer
    locales: {
      'it-it': 'ita',
      'en-en': 'en'
    }
  },
...

Now create a file named i18n.generate.js inside ~/bin:

var fs = require('fs')
var globSync = require('glob').sync
var mkdirpSync = require('mkdirp').sync

var locales = require('../config/project.config').i18n.locales

var filePattern = './i18n/messages/**/*.json'
var outputDir = './i18n/locales/'

// Aggregates the default messages that were extracted from the example app's
// React components via the React Intl Babel plugin. An error will be thrown if
// there are messages in different components that use the same `id`. The result
// is a flat collection of `id: message` pairs for the app's default locale.
var defaultMessages = globSync(filePattern)
  .map((filename) => fs.readFileSync(filename, 'utf8'))
  .map((file) => JSON.parse(file))
  .reduce((collection, descriptors) => {
    descriptors.forEach(function (d) {
      var id = d.id
      var defaultMessage = d.defaultMessage
      if (collection.hasOwnProperty(id)) {
        throw new Error(`Duplicate message id: ${id}`)
      }
      collection[id] = defaultMessage
    })

    return collection
  }, {})
// Create a new directory that we want to write the aggregate messages to
mkdirpSync(outputDir)

// Write the messages to this directory
var messages = {}
Object.keys(locales).forEach(function (l) {
  messages[l] = defaultMessages
})
fs.writeFileSync(outputDir + 'data.json', JSON.stringify(messages, null, 2))

As you can see in lines 33-37 we use the previuos defined locale config to produce a json were all the locales keys have the default messages as value. Now it's enough to translate those strings and all will work properly!

Now we can add this script to the package.json file, so that it can be called in the same way you execute a deploy or compile the application, inside package.json:

...
  "scripts": {
    "geni18n": "better-npm-run geni18n",
    "clean": "rimraf dist",
...
  "betterScripts": {
    "geni18n": {
      "command": "node bin/i18n-generate",
      "env": {
        "DEBUG": "app:*"
      }
    },
    "compile": {
      "command": "node bin/compile",

This way we will'use the following command to generate the translations file:

$ npm run geni18n

Let the user change language

And finally, how do we allow the user to change locale?

Inside a component (i.e. Header.js)you can use:

import React, { PropTypes } from 'react'

const propTypes = {
  onLocaleChange: PropTypes.func.isRequired
}

const Header = (props) => {
  return (
    <header className='app-header'>
      <a onClick={() => props.onLocaleChange('it-it')}>ITA</a>
      <a onClick={() => props.onLocaleChange('en-en')}>EN</a>
    </header>
  )
}

Header.propTypes = propTypes
export default Header

Where onLocaleChage is defined in the container of this component (HeaderContainer.js):

import { connect } from 'react-redux'
import { updateLocale } from 'store/i18n'
import Header from 'components/Header'

const mapStateToProps = (state) => {
  return {
    locale: state.i18n.locale
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    onLocaleChange: updateLocale({ dispatch })
  }
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Header)

This post contains a lot of stuff, probably I forgot something, or made some mistakes here and there, please let me know if it worked for you, and suggestions are very mush appreciated!

Update 2017-03-22

After using this configuration a bit, I found problems with the babel-plugin-react-intl, in particular I couldn't find a way to update the generated files, it seems that it works only the first time, and then does not extract strings anymore, even after removing the previous messages dir.

I found a solution for this now:

  1. Create a .babelrc file in your root directory with the following contents:

    // This configuration is used only when generating translations
    // see buildi18n command in package.json
    {
      "presets": ["es2015", "react", "stage-0"],
      "plugins": ["transform-runtime" ],
      "env": {
        "i18n": {
          "plugins": [
            [ "react-intl", {
              "messagesDir": "./i18n/messages",
              "enforceDescriptions": false
            }]
          ]
        }
      }
    }
    
  2. Remove the correspondig part inside config/project.config.js. Now this plugin will be run only manually and not at every compilation.
  3. Add to your package.json
    ...
    "scripts": {
      "geni18n": "better-npm-run geni18n",
      "buildi18n": "rm -rf i18n/messages && BABEL_ENV=i18n ./node_modules/babel-cli/bin/babel.js --quiet src > /dev/null",
      ...
    
    

Ok, now you can extract the strings with the command:

$ npm run buildi18n

and, as before, generate the translations file with:

$ npm run geni18n

Subscribe to abidibo.net!

If you want to stay up to date with new contents published on this blog, then just enter your email address, and you will receive blog updates! You can set you preferences and decide to receive emails only when articles are posted regarding a precise topic.

I promise, you'll never receive spam or advertising of any kind from this subscription, just content updates.

Subscribe to this blog

Comments are welcome!

blog comments powered by Disqus

Your Smartwatch Loves Tasker!

Your Smartwatch Loves Tasker!

Now available for purchase!

Featured