Software localization

Building an Awesome Magazine App with i18n in React

Learn how to build a React app with strong internationalization and localization support and make it ready for international audiences.
Software localization blog category featured image | Phrase

When building React single-page applications with i18n and l10n, a few concerns come into play: routing and links, locale switching, i18n-ized UI, and of course localized content. Thankfully, React's component modules, Redux's Flux architecture, and a handful of other libraries can help us quickly whip up internationalized prototypes—which we can turn into full-on production apps.

The movie magazine app

Let's assume we've been commissioned to build a clean prototype for μveez, a new internationalized movie magazine app. Our client has asked that we build it as a SPA with the React view framework since React came highly recommended by her colleagues for performant SPAs. We've been asked to build two prototype SPAs, actually: one for the admin panel and one for the front-facing website. The agreed feature list is as follows.

General

  • μveez will initially support Arabic, English, and French

Admin

  • Film director index with names in supported locales
  • Adding a director with translations in supported locales
  • Movie index with titles in supported locales
  • Adding a movie with translated titles and synopses in supported locales

Front

  • Language switching between our supported locales, covering RTL directionality for Arabic
  • Home page with featured directors, quote of the day, and featured movies
  • Movie index
  • Single (show) movie

Framework

Note » I'll assume that you have a basic working knowledge of React and Redux.

Alright, we've worked with React before, so we know that we'll likely want to adopt a Flux architecture. Flux's uni-directional data flow makes it easy to reason about our app state and places this state in one DRY store. Redux is a well-supported Flux implementation, so we'll use that. We'll also need to handle routing and basic i18n UI. To get going quickly, we can pull in Bootstrap for a CSS framework.

Our entire framework (with versions at the time of writing) can, then, look like this.

A Little Organization: Directory Structure

We'll adopt the common differentiation between React state-aware containers and presentational components. We'll also want to place our Redux actions and reducers in logical locations. And we may well need a place for our apps' services. Given that we bootstrap our apps with create-react-app, our directory structure can be this beauty:

/

├── public/

│   ├── api/

│   ├── img/

│   ├── styles/

│   ├── translations/

│   └── index.html

└── src/

    ├── actions/

    ├── components/

    ├── config/

    ├── containers/

    ├── reducers/

    ├── services/

    ├── styles/

    ├── index.js

    └── routes.js

We'll mock our server back-end with JSON files that we place in the public/api directory, and we'll explore these files in detail later. For now, let's get to to building! We'll start with the admin panel.

The Admin Panel

Note » You can see a live demo of the admin panel on Heroku. You can also find all of the panel's source code on GitHub.

Scaffolding

Our Store

Let's setup our Redux store.

/src/index.js

import React from 'react'

import ReactDOM from 'react-dom'

import { Provider } from 'react-redux'

import App from './components/App'

import store from './services/store'

ReactDOM.render(

    <Provider store={store}>

        <App/>

    </Provider>,

    document.getElementById('root')

)

If you've used React and Redux before, this is pretty standard stuff. We're simply wrapping our whole app in the Redux store Provider so that our store is available to any App subcomponent that needs it. To keep things clean, we've housed our store in its own file. Let's take a quick look at it.

/src/services/store.js

import { createStore, applyMiddleware, compose } from 'redux'

import thunk from 'redux-thunk'

import reducer from '../reducers'

let composeEnhancers = null

if (process.env.NODE_ENV === 'development' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) {

    composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__

} else {

    composeEnhancers = compose

}

const store = createStore(reducer, composeEnhancers(applyMiddleware(thunk)))

export default store

We bring in Redux Thunk as middleware to handle asynchronous Flux actions. The handy Redux Devtools browser extension is tied in to help us debug our state in our development environment. Our reducers will be explored as we dive into each of our view models. Now let's get to routing.

Routing

We'll assume that our admin panel UI can be in English only, so we won't worry about internationalized routes until we get to our front-facing app. For now, we can configure our admin's routes as per our requirements.

/src/routes.js

import Home from './components/Home'

import Movies from './components/Movies'

import AddMovie from './containers/AddMovie'

import Directors from './components/Directors'

const routes = [

    {

        path: "/",

        exact: true,

        component: Home

    },

    {

        path: "/directors",

        component: Directors

    },

    {

        path: "/movies",

        exact: true,

        component: Movies

    },

    {

        path: "/movies/new",

        exact: true,

        component: AddMovie

    }

]

export default routes

We'll have a home page, a directors index (which will include a simple Add Director form), and a movies index. The form for adding a movie will be relatively large, so it's broken out into its components. Again, we'll get to each of these components, as well as their respective reducers and actions, a bit later. For now, let's round out our scaffolding by implementing our routing and creating our basic app layout.

/src/components/App.js

import React from 'react'

import { Switch, Route, BrowserRouter as Router } from 'react-router-dom'

import routes from '../routes'

import AppNavbar from '../components/AppNavbar'

import AppFooter from '../components/AppFooter'

export default () => (

    <div style={{paddingTop: "80px"}}>

        <Router>

            <div>

                <AppNavbar />

                <div className="container">

                    <main id="main" role="main">

                        <Switch>

                            {routes.map((route, index) => (

                                <Route

                                    key={index}

                                    path={route.path}

                                    exact={route.exact}

                                    component={route.component}

                                />

                            ))}

                        </Switch>

                    </main>

                </div>

                <AppFooter />

            </div>

        </Router>

    </div>

)

We spin over our configured routes and render a Route component for each one. We wrap the majority of our app in the requisite BrowserRouter component (aliased as Router), and use Bootstrap's .container for layout.

Note » If you're not familiar with React Router, check out its excellent documentation.

Our AppNavbar and AppFooter are pretty much presentational here, so we'll skip their dissection for brevity. You can check them out in the GitHub repo if you're curious about how they're coded.

You may have noticed that our root component is Home, which means that we'll render that component when we hit our / route. Home is a simple, stateless functional component. Let's take a look at it.

/src/components/Home.js

import React from 'react'

import {

    Row,

    Col,

    Card,

    CardHeader,

    ListGroup,

    ListGroupItem,

} from 'reactstrap'

import { Link } from 'react-router-dom'

export default () => (

    <Row className="justify-content-center">

        <Col sm="10" md="7" lg="5" xl="4">

            <h2>Welcome!</h2>

            <Card>

                <CardHeader tag="h3" className="h4">

                    Manage

                </CardHeader>

                <ListGroup flush>

                    <ListGroupItem>

                        <Link to="/directors">Directors</Link>

                    </ListGroupItem>

                    <ListGroupItem>

                        <Link to="/movies">Movies</Link>

                    </ListGroupItem>

                </ListGroup>

            </Card>

        </Col>

    </Row>

)

We use reactstrap's Bootstrap components to style, responsively size, and center our list of Links. That's it really.

Here's a look at our app so far.

App prototype | Phrase

Not bad for a prototype. Thank you, Twitter Bootstrap 🙏🏽.

Alright, that's our admin panel scaffolded. Let's to get to our model CRUD. We'll start with directors.

Director CRUD

We can start with a Directors component that will contain our AddDirectors form and DirectorList index.

Note » As per our client's requirements, we're skipping updating and deleting director functionality. This is a proof-of-concept prototype after all.

/src/components/Directors.js

import React from 'react'

import AddDirector from '../containers/AddDirector'

import DirectorList from '../containers/DirectorList'

export default () => (

    <div>

        <h2 style={{marginBottom: "20px"}}>Directors</h2>

        <AddDirector style={{marginBottom: "20px"}}/>

        <DirectorList />

    </div>

)

Our DirectorList will need to load data from our mock API. Let's take a look at some of this JSON.

/src/public/api/directors.json (excerpt)

[

    {

        "id": 1,

        "name_ar": "كرستوفر نولان",

        "name_en": "Christopher Nolan",

        "name_fr": "Christopher Nolan"

    },

    {

        "id": 2,

        "name_ar": "ميشيل جوندري",

        "name_en": "Michael Gondry",

        "name_fr": "Michael Gondry"

    },

    // ...

]

This is how we would expect a request like GET /admin/api/directors to respond. We can consume this "API" and present it in our views. Let's take a look at what our director list would look like.

Director list in app | Phrase

A simple <table> should be good to get us started. We just need to pull in the data from our JSON file and load it into this table, minding our separation of concerns. If you know the ways of Reacty Reduxy Fluxy kung-fu, you know what's next: a reducer, young grasshopper.

/src/reducers/directors.js 

import _ from 'lodash'

const INITIAL_STATE = {

    directors: [],

}

export default (state = INITIAL_STATE, action) => {

    let directors = []

    switch(action.type) {

        case 'ADD_DIRECTORS':

            directors = _.unionBy(action.directors, state.directors, 'id')

            return {

                ...state,

                directors

            }

        default:

            return state

    }

}

Our ADD_DIRECTORS action will reduce our state to the given list of directors, merging it in with whatever directors are currently loaded. We use the popular utility library, Lodash, and its handy unionBy function, to help us with the merge.

Next, we'll need to write a couple of actions that fetch our existing directors and add them to our app state.

/src/actions/index.js

export const fetchDirectors = () => dispatch => (

    fetch('/api/directors.json')

        .then(response => response.json())

        .then(directors => dispatch(addDirectors(directors)))

        .catch(err => console.error(err))

)

export const addDirectors = directors => ({

    type: 'ADD_DIRECTORS',

    directors

})

The fetchDirectors action is asynchronous. The Redux Thunk middleware will notice that we're returning a function from fetchDirectors and step in to handle the action. It will also provide the returned function with a dispatcher to allow us to call other actions.

We use the standard fetch API to make an async request that asks for our mock JSON. Once we get that JSON, we call our addDirectors action with the directors we've received. Of course, our directors reducer is already set up to handle this action and update our app state.

Note » fetch is widely supported, but not 100%. Some older browsers do not support the relatively new API. If you want something closer to complete browser coverage, you may want to add a polyfill or use a library like axios for your XHR calls.

We can now build out our view.

/src/containers/DirectorList.js

import { Table } from 'reactstrap'

import { connect } from 'react-redux'

import React, { Component } from 'react'

import { fetchDirectors } from '../actions'

class DirectorList extends Component {

    componentDidMount() {

        this.props.fetchDirectors()

    }

    render() {

        return (

            <Table>

                <thead className="thead-dark">

                    <tr>

                        <th>id</th>

                        <th

                            className="text-right"

                            style={{paddingRight: "5rem"}}

                        >

                            Name (Arabic)

                        </th>

                        <th>Name (English)</th>

                        <th>Name (French)</th>

                    </tr>

                </thead>

                <tbody>

                    {this.props.directors.map(director => (

                        <tr key={director.id}>

                            <td>{director.id}</td>

                            <td className="text-right"

                                style={{paddingRight: "5rem", maxWidth: "8rem"}}

                            >

                                {director.name_ar}

                            </td>

                            <td>{director.name_en}</td>

                            <td>{director.name_fr}</td>

                        </tr>

                    ))}

                </tbody>

            </Table>

        )

    }

}

export default connect(

    state => ({ directors: state.directors.directors }),

    { fetchDirectors }

)(DirectorList)

We simply map state.directors.directors to a directors prop in our React Component, fetch the existing directors when our component has mounted, and render out our directors as table rows. Bada boom, bada bing.

Let's get to adding a director in our demo admin app. First, we'll need some new bits of state.

/src/reducers/directors.js (excerpt)

const INITIAL_STATE = {

    lastId: 0,

    directors: [],

    newDirector: {

        name_ar: '',

        name_en: '',

        name_fr: '',

    },

}

// ...

Since we don't have a real back-end, we'll track the lastId of an added director in the browser memory. We'll also keep track of the entered translations of a newDirector as the user enters them.  We can use this new state to add the new director at the appropriate time.

Let's actually track our lastId when we add directors.

/src/reducers/directors.js (excerpt)

// ...

        case 'ADD_DIRECTORS':

            directors = _.unionBy(action.directors, state.directors, 'id')

            lastId = _.maxBy(directors, 'id').id

            return {

                ...state,

                lastId,

                directors

            }

// ...

Whenever we add directors in bulk, we get the lastId added to the "back-end" by getting the largest id in our current set of our directors.

Alright, let's get to our view on adding directors. While we're in the directors reducer, let's add a new action handler for tracking translation user input.

/src/reducers/directors.js (excerpt)

// ...

import { defaultOnUndefinedOrNull } from '../services/util'

//...

        case 'SET_NEW_DIRECTOR_NAME':

            return {

                ...state,

                newDirector: {

                    name_ar: defaultOnUndefinedOrNull(action.name_ar, state.newDirector.name_ar),

                    name_en: defaultOnUndefinedOrNull(action.name_en, state.newDirector.name_en),

                    name_fr: defaultOnUndefinedOrNull(action.name_fr, state.newDirector.name_fr),

                }

            }

// ...

To avoid undefined and null values—which will cause React to throw an error when our AddDirector component is populating its text fields—we default our translation values to the current state using the defaultOnUndefinedOrNull utility function. This function checks if its first parameter is undefined or null, and if it returns the second parameter. Otherwise, it simply returns the first parameter.

When the user starts typing her Arabic translation, for example, the English and French translations will cycle through our app state, remaining as '' (empty strings). When she moves on to writing her English translation, the Arabic translation will be maintained as she enters it.

A setNewDirector action will be dispatched to track our new director translations state.

/src/actions/index.js (excerpt)

// ...

export const setNewDirector = ({ name_ar, name_en, name_fr }) => ({

    type: 'SET_NEW_DIRECTOR_NAME',

    name_ar,

    name_en,

    name_fr,

})

// ...

Of course, our AddDirector view will be the sheer epitome of UX design.

Director list with add director bar | Phrase

Alan Cooper would be proud. OK, OK. Let's get to the code.

/src/containers/AddDirector.js

import { connect } from 'react-redux'

import React, { Component } from 'react'

import {

    Card,

    Form,

    Button,

    CardBody,

    CardTitle,

} from 'reactstrap'

import { setNewDirector } from '../actions'

import AddDirectorTranslation from '../components/AddDirectorTranslation'

class AddDirector extends Component {

    _updateTranslation(key, value) {

        this.props.setNewDirector({ [key]: value })

    }

    render() {

        return (

            <Card style={this.props.style}>

                <CardBody>

                    <CardTitle>Add Director with Name</CardTitle>

                    <Form inline>

                        <AddDirectorTranslation

                            dir="rtl"

                            name="name_ar"

                            label="Arabic"

                            value={this.props.name_ar}

                            onChange={value => this._updateTranslation("name_ar", value)}

                        />

                        <AddDirectorTranslation

                            name="name_en"

                            label="English"

                            value={this.props.name_en}

                            onChange={value => this._updateTranslation("name_en", value)}

                        />

                        <AddDirectorTranslation

                            name="name_fr"

                            label="French"

                            value={this.props.name_fr}

                            onChange={value => this._updateTranslation("name_fr", value)}

                        />

                        <Button>Add</Button>

                    </Form>

                </CardBody>

            </Card>

        )

    }

}

export default connect(

    state => {

        const { name_ar, name_en, name_fr } = state.directors.newDirector

        return { name_ar, name_en, name_fr }

    },

    {

        setNewDirector,

    }

)(AddDirector)

Reactstrap's presentational components are pulled in for styling. We also wire up our setNewDirector action to be dispatched whenever our AddDirectorTranslations are changed ie. whenever the user enters text. And, since AddDirectorTranslation’s internal text input is controlled, we make sure to pass it to the relevant part of our newDirector state. This way we ensure uni-directional data flow. Our state is always the single source of truth about the newDirector’s translations. This keeps things nice and easy to reason about.

Let's dive into the AddDirectorTranslation component just to see what it's composed of.

/src/components/AddDirectorTranslation.js

import React from 'react'

import { FormGroup, Label, Input } from 'reactstrap'

export default props => {

    const dir = props.dir || 'ltr'

    const { name, label, value, onChange } = props

    return (

        <FormGroup className="mb-2 mr-sm-2 mb-sm-0">

            <Label

                for={name}

                className="mr-sm-2"

            >

                {label}

            </Label>

            <Input

                dir={dir}

                id={name}

                type="text"

                name={name}

                value={value}

                onChange={e => onChange(e.target.value)}

            />

        </FormGroup>

    )

}

We default our input's directionality to left-to-right if none is provided by the developer. We also connect the synthetic onChange input event to the parent component, calling its provided onChange, delegating upwards. AddDirectorTranslation is effectively a presentational component that offers connections to its text input.

OK, let's go back to our directors reducer. We'll update it to include the action-handling logic that will add a new director to our state from user input.

/src/reducers/directors.js (excerpt)

// ...

        case 'ADD_DIRECTOR':

            lastId = state.lastId + 1

            directors = [

                ...state.directors,

                {

                    id: lastId,

                    name_ar: action.name_ar,

                    name_en: action.name_en,

                    name_fr: action.name_fr,

                }

            ]

            return {

                ...state,

                lastId,

                directors,

                newDirector: {

                    name_ar: '',

                    name_en: '',

                    name_fr: '',

                },

            }

// ...

ADD_DIRECTOR is handled by first incrementing our lastId, since we're going to be upping the count of the directors collection in our state. We use this incremented value as the id of the director we're adding, and bring in the user-entered name translations of the director while we're at it. To clear out the text inputs, we make sure to reset the user-entered translation state when we reduce.

OK, now we'll need a quick action that we can dispatch to add the new director.

/src/actions/index.js (excerpt)

// ...

export const addDirector = ({ name_ar, name_en, name_fr }) => ({

    type: 'ADD_DIRECTOR',

    name_ar,

    name_en,

    name_fr,

})

// ...

We can now call addDirector from our view to finish up our add director functionality.

/src/containers/AddDirector.js (excerpt)

// ...

import { addDirector, setNewDirector } from '../actions'

class AddDirector extends Component {

    // ...

    _addDirector() {

        const { name_ar, name_en, name_fr } = this.props

        if (name_ar && name_en && name_fr) {

            this.props.addDirector({ name_ar, name_en, name_fr })

        }

    }

    render() {

        return (

            <Card style={this.props.style}>

                <CardBody>

                    <CardTitle>Add Director with Name</CardTitle>

                    <Form inline>

                        <AddDirectorTranslation ... />

                        {/* ... */}

                        <Button onClick={() => this._addDirector()}>Add</Button>

                    </Form>

                </CardBody>

            </Card>

        )

    }

}

export default connect(

    state => {

        // ...

    },

    {

        addDirector,

        setNewDirector,

    }

)(AddDirector)

We call _addDirector() when our Add button is clicked. The function does some rudimentary input validation, making sure all the translations have values, and then dispatches the addDirector action.

Directors list with functioning add director functionality | Phrase

Note » It's "Michel" Gondry, not "Michael". The guy's French for God's sake.

Of course, in a production app, we would be making an API call when we add a director: something like POST /admin/api/movies with the translated name params. We're just demoing here though, so we'll omit the server call for brevity.

Et voilà! Our add director demo is working 🚀

The movie index and add movie functionality are essentially more complex versions of the DirectorList and AddDirector components, respectively. From an i18n/l10n perspective, they shed no new light on administrating models, so I won't go over movie admin here. You can play with movie admin in the demo app, and peruse all of the admin movie code in the GitHub repo.

The Front-facing Magazine App

Alright, we show the client our admin panel prototype, and she wonders why there's no user authentication. We justify that this is just a proof of concept and that the live app will of course have enforced SSL and best-practice auth. She squints at us and then asks to see the front-facing, public app. We talk about PM, that we're showing her what we have as soon as we build it, and that we'll get to the public app next. She squints harder at us.

Let's roll up our sleeves and get to cooking our second dish.

Note » You can see a live demo of the front-facing app on heroku. You can also find all of the app source code on GitHub.

Scaffolding

The scaffolding for our front-facing app is largely the same as our admin panel; it will have a very similar directory structure and a Redux store. There are, however, some differences regarding i18n and routing. Remember that, unlike our admin panel, our front-facing app needs to be be internationalized and localized. In fact, a lot of the scaffolding unique to our public app deals with just this i18n and l10n. Let's take a look.

Configuration

/src/config/i18n.js

export const defaultLocale = "en"

export const locales = [

    {

        code: "ar",

        name: "عربي",

        dir: "rtl"

    },

    {

        code: "en",

        name: "English",

        dir: "ltr"

    },

    {

        code: "fr",

        name: "Français",

        dir: "ltr"

    }

]

Configuring our supported locales in one place keeps things DRY and facilitates reuse. We'll want locale names in their respective languages to use in a language switcher. Since we are supporting Arabic, we'll also want to know a locale's directionality when we switch to it.

Routing

/src/routes.js

import React from 'react'

import { Redirect } from 'react-router-dom'

import Home from './components/Home'

import Movies from './containers/Movies'

import { defaultLocale } from './config/i18n'

import SingleMovie from './containers/SingleMovie'

import { localizeRoutes } from './services/i18n/util'

const routes = [

    {

        path: "/",

        exact: true,

        localize: false,

        component: () => <Redirect to={`/${defaultLocale}`} />

    },

    {

        path: "/movies/:id",

        component: SingleMovie

    },

    {

        path: "/movies",

        component: Movies

    },

    {

        path: "/",

        component: Home

    }

]

export default localizeRoutes(routes)

Our locale determination will be based on the current URI. So /fr/movies will respond with a French version of the movies index, for example. To make sure we always have a locale explicitly selected, we redirect the / route to our default locale. In this case, it's English, so / will redirect to /en. React Router makes this quite easy with its Redirect component.

Notice the localizeRoutes(routes) call above. We provide the localizeRoutes function so we don't have to include our locale parameter when we specify each of our routes. In actuality, however, we want all our routes prefixed by a segment corresponding to the current locale. So /movies/:id should actually be /:locale/movies/:id. We can then use this :locale parameter to determine our app's current locale. Our localizeRoutes achieves this parameter prefixing, making use of our special localize option on our configured routes. Let's see how this simple mapper works.

/src/services/i18n/util.js (excerpt)

export function localizeRoutes (routes) {

    return routes.map(route => {

        // we default to localizing

        if (route.localize !== false)  {

            return {

               ...route,

               path: prefixPath(route.path, ':locale')

            }

        }

        return { ...route }

    })

}

We're just prefixing every route passed to us with the /:locale/ route parameter and returning the prefixed routes. A dedicated l10n component will consume this parameter and use it to set our current locale. We'll see this in action a bit later.

Note » We use a simple prefixPath function above to separate our concerns. Check out the function in the GitHub repo if you want.

OK, let's see how this all comes together. First, let's take a look at our App container.

/src/containers/App.js

import React from 'react'

import { connect } from 'react-redux'

import {

    Route,

    Switch,

    BrowserRouter as Router

} from 'react-router-dom'

import routes from '../routes'

import Localizer from './Localizer'

import AppNavbar from '../components/AppNavbar'

import AppFooter from '../components/AppFooter'

const App = props => (

    <div style={{paddingTop: "80px"}}>

        <Router>

            <Localizer>

                {props.uiTranslationsLoaded &&

                    <div>

                        <AppNavbar />

                        <div className="container">

                            <main id="main" role="main">

                                <Switch>

                                    {routes.map((route, index) => (

                                        <Route

                                            key={index}

                                            path={route.path}

                                            exact={route.exact}

                                            component={route.component}

                                        />

                                    ))}

                                </Switch>

                            </main>

                        </div>

                        <AppFooter />

                    </div>

                }

            </Localizer>

        </Router>

    </div>

)

export default connect(

    state => ({ uiTranslationsLoaded: state.l10n.uiTranslationsLoaded })

)(App)

OK, most of what's up there looks quite similar to our admin panel.  We do have a Switch, however, which may be new to you, and a custom Localizer.

The Switch component makes routing more akin to what we're used to in server-side frameworks, meaning that it will render the first route it matches. If you remember, we had our /movies/:id route come before our /movies route in our config. Our Switch will make sure that /movies/:id route catches, and that we don't fall through to the /movies route, when we hit /movies/1/.

Note » Read more about the Switch component in the React Router documentation.

 The Localizer Higher Order Component

The Localizer container is our own special sauce for setting the current locale based on the active URI. Notice that our Localizer sits inside the Router component. This is important since Localizer will need the /:locale route parameter we defined in our routes to do its work.

/src/containers/Localizer.js

import { Component } from 'react'

import { connect } from 'react-redux'

import { withRouter } from 'react-router-dom'

import { locales } from '../config/i18n'

import { setUiLocale } from '../services/i18n'

import { switchHtmlLocale, getLocaleFromPath } from '../services/i18n/util'

import { changeLocale, setUiTranslationsLoaded, setUiTranslationsLoading } from '../actions'

class Localizer extends Component {

    constructor(props) {

        super(props)

        this.setLocale(getLocaleFromPath(this.props.location.pathname), true)

        this.props.history.listen(location => {

            this.setLocale(getLocaleFromPath(location.pathname))

        })

    }

    /**

     * Set the lang and dir attributes in the <html> DOM element, and

     * initialize our i18n UI library.

     *

     * @param {string} newLocale

     * @param {bool} force

     */

    setLocale(newLocale, force = false) {

        if (force || newLocale !== this.props.locale) {

            this.props.changeLocale(newLocale)

            switchHtmlLocale(

                newLocale,

                locales.find(l => l.code === newLocale).dir,

                { withRTL: ['/styles/vendor/GhalamborM/bootstrap-rtl.css'] }

            )

            this.props.setUiTranslationsLoading(true)

            setUiLocale(newLocale)

                .then(() => this.props.setUiTranslationsLoaded(true))

                .catch(() => this.props.setUiTranslationsLoaded(false))

        }

    }

    render() {

        return this.props.children

    }

}

export default withRouter(

    connect(

        state => ({ locale: state.l10n.locale }),

        {

            changeLocale,

            setUiTranslationsLoaded,

            setUiTranslationsLoading,

        }

    )(Localizer)

)

Ok, this may be a lot to take in. So let's break it up.

Setting the Locale

When we construct our Localizer, we call setLocale to do some locale setup. By default and to be efficient, setLocale will check to see if our locale has actually changed before doing its work. Since there will be no change on app initialization, we force setLocale to do its setup via the second, boolean parameter. We then listen for URI changes and call setLocale whenever we get a newly requested URI.

Note » We're using a simple utility function called getLocaleFromPath to extract the locale URI segment from the current locale. Check it out in the GitHub repo.

Alright, let's take a look at what setLocale actually does.

/src/containers/Localizer.js (excerpt)

    setLocale(newLocale, force = false) {

        if (force || newLocale !== this.props.locale) {

            this.props.changeLocale(newLocale)

            switchHtmlLocale(

                newLocale,

                locales.find(l => l.code === newLocale).dir,

                { withRTL: ['/styles/vendor/GhalamborM/bootstrap-rtl.css'] }

            )

            this.props.setUiTranslationsLoading(true)

            setUiLocale(newLocale)

                .then(() => this.props.setUiTranslationsLoaded(true))

                .catch(() => this.props.setUiTranslationsLoaded(false))

        }

    }

The function is responsible for a couple of things. It makes sure that our <html> element is synced correctly with our current locale. setLocale also lets our UI i18n library know what l10n the library should load, again based on the current locale. The state of UI translation file loading is tracked as our UI i18n library initializes in setUiLocale. We'll dive into switchHtmlLocale and setUiLocale a bit later. For now, let's continue working through our Localizer.

/src/containers/Localizer (excerpt)

    render() {

        return this.props.children

    }

Since our Localizer exists solely for setting the current locale, it doesn't need to render anything. It's a higher order React component that will wrap other components, and we achieve this wrapping by rendering out Localizer’s children. Now we export our module.

/src/containers/Localizer (excerpt)

export default withRouter(

    connect(

        state => ({ locale: state.l10n.locale }),

        {

            changeLocale,

            setUiTranslationsLoaded,

            setUiTranslationsLoading,

        }

    )(Localizer)

)

At the bottom of our file, where we normally export a connected component or a plain old React component, we're doing something a bit different. After we connect a bit of state that tracks our current locale and some locale actions, we wrap everything up in withRouter. We'll get to withRouter in a minute.

First, let's take a brief look at our locale state. It's really simple stuff. We have two bits of locale state that we track.

/src/reducer/l10n.js (excerpt)

import { defaultLocale } from '../config/i18n'

const INITIAL_STATE = {

    locale: defaultLocale,

    uiTranslationsLoaded: false,

}

export default (state = INITIAL_STATE, action) => {

//...

locale is just the current locale code e.g. "ar" for Arabic. The uiTranslationsLoaded boolean is used to track whether the UI translation files for the current locale have been loaded successfully. I'll spare you the rest of the l10n reducer and its associated actions. They really just set the locale string and flip the uiTranslationsLoaded boolean. Nothing fancy at all happening there.

Note » You can check out the l10n reducer and actions in the GitHub repo.

Let's get back to our Localizer.

/src/containers/Localizer (excerpt)

export default withRouter(

    connect(

        state => ({ locale: state.l10n.locale }),

        {

            changeLocale,

            setUiTranslationsLoaded,

            setUiTranslationsLoading,

        }

    )(Localizer)

)

We wrap our normal React Redux connect call in withRouter. withRouter is a higher-order component that provides routing information to its children.

If you remember, our Localizer’s constructor made use of some seemingly magical props.

/src/containers/Localizer (excerpt)

    constructor(props) {

        super(props)

        this.setLocale(getLocaleFromPath(this.props.location.pathname), true)

        this.props.history.listen(location => {

            this.setLocale(getLocaleFromPath(location.pathname))

        })

    }

It's the withRouter call that gives our Localizer access to the history prop, which we use to listen for URI changes in our app. It also gives us access to a handy location prop, which we can use to retrieve the current URI.

Switching the Document's Locale

When we set our current locale in the Localizer, we made a call to switchHtmlLocale.

/src/containers/Localizer.js (excerpt)

    setLocale(newLocale, force = false) {

        if (force || newLocale !== this.props.locale) {

            this.props.changeLocale(newLocale)

            switchHtmlLocale(

                newLocale,

                locales.find(l => l.code === newLocale).dir,

                { withRTL: ['/styles/vendor/GhalamborM/bootstrap-rtl.css'] }

            )

            this.props.setUiTranslationsLoading(true)

            setUiLocale(newLocale)

                .then(() => this.props.setUiTranslationsLoaded(true))

                .catch(() => this.props.setUiTranslationsLoaded(false))

        }

    }

Let's step into this function.

/src/services/i18n/util.js (excerpt)

export function switchHtmlLocale (locale, dir, opt = {}) {

    const html = window.document.documentElement

    html.lang = locale

    html.dir = dir

    if (opt.withRTL) {

        if (dir === 'rtl') {

            opt.withRTL.forEach(stylesheetURL => loadAsset(stylesheetURL, 'css'))

        } else {

            opt.withRTL.forEach(stylesheetURL => removeAsset(stylesheetURL, 'css'))

        }

    }

}

We first make sure that our <html lang="ar" dir="rtl"> reflects the current locale and directionality. Any special stylesheets that are needed when our directionality is right-to-left are loaded and removed when our directionality is left-to-right. We use this option in our calling code to load Bootstrap RTL styles.

Note » The loadAsset and removeAsset functions do pretty much what you think they do. You can check them out in the GitHub repo.

UI i18n

Our Localizer is responsible for initializing our UI i18n library. It does this via a call to setUilocale.

/src/containers/Localizer.js (excerpt)

    setLocale(newLocale, force = false) {

        if (force || newLocale !== this.props.locale) {

            this.props.changeLocale(newLocale)

            switchHtmlLocale(

                newLocale,

                locales.find(l => l.code === newLocale).dir,

                { withRTL: ['/styles/vendor/GhalamborM/bootstrap-rtl.css'] }

            )

            this.props.setUiTranslationsLoading(true)

            setUiLocale(newLocale)

                .then(() => this.props.setUiTranslationsLoaded(true))

                .catch(() => this.props.setUiTranslationsLoaded(false))

        }

    }

For UI i18n, we're using i18next and providing some simple wrappers around it. Let's peek in.

/src/services/i18n/index.js

import i18next from 'i18next'

import { formatDate } from './util'

export const setUiLocale = (locale) => {

    return fetch(`/translations/${locale}.json`)

            .then(response => response.json())

            .then(loadedResources => (

                new Promise((resolve, reject) => {

                    i18next.init({

                        lng: locale,

                        debug: true,

                        resources: { [locale]: loadedResources },

                        interpolation: {

                            format: function (value, format, locale) {

                                if (value instanceof Date) {

                                    return formatDate(value, format, locale)

                                }

                                return value

                            }

                        }

                    }, (err, t) => {

                        if (err) {

                            reject(err)

                            return

                        }

                        resolve()

                    })

                })

            ))

            .catch(err => Promise.reject(err))

}

export const t = (key, opt) => i18next.t(key, opt)

Loading Translation Files

The first thing we do is pull in the UI translation file for our given locale. We're assuming that we're placing our translation files in /public/translations/. The JSON for these is pretty straightforward.

/public/translations/fr.json (excerpt)

{

    "translation": {

        "app_name": "μveez",

        "a_react_demo": "une démo d'i18n React",

        "directors": "Réalisateurs",

        "movies": "Films",

        // ...

    }

}

i18next namespaces its translations under a translation key by default, so we adhere to that convention. Our translations are just key / value pairs. Done like dinner.

Once our translation file is loaded, we initialize i18next with the file's JSON. From that point on we can use our  t() wrapper—which you may have noticed above—to return translation values by key from the currently loaded locale file.

In our views...

import { t } from '../services/i18n'

// ...

{{t('app_name')}}

{{t('directed_by', { director: 'Michel Gondry' })}}

We can also interpolate values using i18next. Notice that we're passing in a map with a director key in our second call to t above. Our translation copy can have a placeholder that corresponds to this key.

/public/translations/fr.json (excerpt)

"directed_by": "Réalisé par {{director}}"

The {{director}} placeholder will be replaced by "Michel Gondry" before t outputs the value of directed_by. i18next really simplifies our UI i18n and l10n.

Formatting Dates

i18next doesn't support formatting dates itself. It does, however, provide a way for us to inject a date formatting interpolator when we initialize it. Notice that our interpolation.format function checks to see if the given value is a date, and delegates to formatDate if it is.

/src/services/i18n/index.js (excerpt)

import i18next from 'i18next'

import { formatDate } from './util'

// ...

                    i18next.init({

                        // ...

                        interpolation: {

                            format: function (value, format, locale) {

                                if (value instanceof Date) {

                                    return formatDate(value, format, locale)

                                }

                                return value

                            }

                        }

                    }

// ...

We'll jump into our date formatter in a minute. First, let's see how we want to use it.

/public/translations/ar.json (excerpt)

"published_on": "نشر في {{date, year:numeric;month:long}}"

i18next allows to control the parameters we pass to our format interpolator. Given the above, if we were to call t('published_on', new Date('2018-02'))interpolation.format would receive "year:numeric;month:long" as its second parameter.

It's up to us to handle this format. We could pull in a library like Moment.js for date formatting, but for a proof of concept like this Moment is overkill. Instead, we'll use the Intl API built into most modern browsers.

let format = new Intl.DateTimeFormat("en", {year: "numeric", month: "short"}).format

let value = new Date('2018-02-12')

console.log(format(value)) // → "Feb 2018"

The Intl.DateTimeFormat constructor accepts a variety of formatting options that are well-documented. We can simply pass these along in our date formats when we write our translation files.

/public/translations/fr.json (excerpt)

"published_on": "Publié le {{date, year:numeric;month:short}}",

"published_on_date_only": "{{date, year:numeric;month:long}}"

All we have to do now is take these format strings and convert them to objects that Intl.DateTimeFormat understands. That's exactly what our custom date interpolater, formatDate, does.

/src/services/i18n/util.js (excerpt)

export function formatDate (value, format, locale) {

    const options = {}

    format.split(';').forEach(part => {

        const [key, value] = part.split(':')

        options[key.trim()] = value.trim()

    })

    try {

        return new Intl.DateTimeFormat(locale, options).format(value)

    } catch (err) {

        console.error(err)

    }

}

We break up the format options along ; then we break each segment up further into its key and value, and we use those to build our options object. After that, we do our Intl.DateTimeFormat thing, gracefully handling any errors that could be caused by invalid user options.

OK, that's it for our scaffolding. Let's get to our views.

UI: Our React Views

Navigation and The Language Switcher

We'll start with our main app nav.

Dome app with language switcher and nav bar | Phrase

/src/components/AppNavbar.js

import React, { Component } from 'react'

import { Link } from 'react-router-dom'

import {

    Nav,

    Navbar,

    NavItem,

    Collapse,

    DropdownMenu,

    NavbarToggler,

    DropdownToggle,

    UncontrolledDropdown,

} from 'reactstrap'

import logo from '../logo.svg'

import { t } from '../services/i18n'

import { locales } from '../config/i18n'

import LocalizedLink from '../containers/LocalizedLink'

class AppNavbar extends Component {

    constructor(props) {

        super(props)

        this.state = { isOpen: false }

    }

    toggle() {

        this.setState(prevState => ({ isOpen: !prevState.isOpen }))

    }

    render() {

        return (

            <Navbar fixed="top" color="light" light expand="md">

                <LocalizedLink to="/" className="navbar-brand">

                    <img

                        src={logo}

                        width="30"

                        height="30"

                        className="d-inline-block align-top"

                        alt={t('app_name')}

                    />

                    {t('app_name')}

                </LocalizedLink>

                <NavbarToggler onClick={() => this.toggle()} />

                <Collapse isOpen={this.state.isOpen} navbar>

                    <span className="navbar-text small d-inline-block pr-4">

                        — {t('a_react_demo')}

                    </span>

                    <Nav className="mr-auto" navbar>

                        <NavItem>

                            <LocalizedLink to="/movies" className="nav-link">

                                {t('movies')}

                            </LocalizedLink>

                        </NavItem>

                    </Nav>

                    <Nav className="ml-auto" navbar>

                         <UncontrolledDropdown nav inNavbar>

                            <DropdownToggle nav caret>

                                <span

                                    role="img"

                                    aria-label="globe"

                                    className="globe-icon"

                                >

                                    🌐

                                </span>

                                {t('language')}

                            </DropdownToggle>

                            <DropdownMenu right>

                                {locales.map(locale => (

                                    <Link

                                        key={locale.code}

                                        to={`/${locale.code}`}

                                        className="dropdown-item"

                                    >

                                        {locale.name}

                                    </Link>

                                ))}

                            </DropdownMenu>

                        </UncontrolledDropdown>

                    </Nav>

                </Collapse>

            </Navbar>

        )

    }

}

export default AppNavbar

Most of the components we're using here are Bootstrap presentations that Reactstrap provides for us. You'll notice that we're using our t() function instead of hard-coding any UI text. This ensures that the text is internationalized and pulled in from the current locale's translation file.

We're also pulling in a custom LocalizedLink along with React Router's usual Link component. Take a gander with me.

/src/containers/LocalizedLink.js

import { connect } from 'react-redux'

import { Link } from 'react-router-dom'

import React, { Component } from 'react'

import { prefixPath } from '../services/util'

class LocalizedLink extends Component {

    render() {

        const { to, locale, className, children } = this.props

        return (

            <Link

                className={className}

                to={prefixPath(to, locale)}

            >

                {children}

            </Link>

        )

    }

}

export default connect(

    state => ({ locale: state.l10n.locale })

)(LocalizedLink)

Remember that prefixPath function that we used to prefix our routes with the locale param? Well, now we're using it to prefix the given URI, to, with the actual current locale. We're pulling in the current locale from our single source of truth on the subject: our handy dandy Redux state.

The Language Switcher

Back to AppNavbar. This piece of JSX is of particular interest.

/src/container/AppNav.js (excerpt)

<DropdownMenu right>

    {locales.map(locale => (

        <Link

            key={locale.code}

            to={`/${locale.code}`}

            className="dropdown-item"

        >

            {locale.name}

        </Link>

    ))}

</DropdownMenu>

Since our supported locales are stored in one central config, we pull them in with import { locales } from '../config/i18n near the top of our file. All we have to do then is spin over them and output links to /ar, /en, and /fr. Our routing and Localizer take care of the rest. Disco.

Now we can build out our Home component.

Home Sweet Home

/src/components/Home.js

import React from 'react'

import Quote from '../containers/Quote'

import FeaturedMovies from '../containers/FeaturedMovies'

import FeaturedDirectors from '../containers/FeaturedDirectors'

export default () => (

    <div>

        <FeaturedDirectors />

        <Quote />

        <FeaturedMovies />

    </div>

)

Like good React developers, we componentize our Home sections and pull them in. Now, instead of boring you with building out all of the Home containers, we'll deep-dive into one of them so that we can get an idea of a whole vertical.

Note » You can see the rest of the Home containers, along with the rest of the app code, in the GitHub repo.

Featured Movies

We'll focus on the FeaturedMovies container. Let's take a look at our mock API first; we represent it with JSON files tucked away in /public/api/.

/public/api/en/movies.json (excerpt)

[

    {

        "id": 1,

        "is_featured": true,

        "published_on": "2008-07",

        "title": "The Dark Knight",

        "synopsis": "When the menace known as the Joker emerges...",

        "thumbnail_url": "/img/movies/dark_knight_tn.jpg",

        "image_url": "https://images-na.ssl-images-amazon.com/images/...",

        "director": {

            "id": 1,

            "name": "Christopher Nolan"

        }

    }, {

        "id": 2,

        "is_featured": true,

// ...

We'd expect our app's API to return something like this if we made a GET /en/movies request. To round out our mock API, we have one of these JSON files for each of our supported locales. Now to our movie reducer.

Our movie state is nice and terse.

/src/reducers/movies.js (excerpt)

const INITIAL_STATE = {

    movies: [],

    featured: [],

}

const movies = (state = INITIAL_STATE, action) => {

    switch(action.type) {

        case 'ADD_MOVIES':

            return {

                ...state,

                movies: [...action.movies],

                featured: action.movies.filter(m => m.is_featured)

            }

        default:

            return state

    }

}

export default movies

We make sure to keep a featured subset of our movie collection each time we add new movies. Now, of course, we need something to act on this state.

/src/actions/index.js (excerpt)

export const fetchMovies = () => (dispatch, getState) => {

    const { locale }  = getState().l10n

    return fetch(`/api/${locale}/movies.json`)

                .then(response => response.json())

                .then(movies => dispatch(addMovies(movies)))

                .catch(err => console.error(err))

}

export const addMovies = movies => ({

    type: 'ADD_MOVIES',

    movies,

})

Pretty standard stuff here. Notice, however, that we're pulling in our current state by using Redux Thunk's getState parameter. This allows to figure out the current locale without requiring it from our calling code, so we can pull in the right movie localization. OK, let's use this funky fluxy flow in our views.

/src/containers/FeaturedMovies.js

import { connect } from 'react-redux'

import { CardDeck } from 'reactstrap'

import React, { Component } from 'react'

import { t } from '../services/i18n'

import { fetchMovies } from '../actions'

import FeaturedMovie from '../components/FeaturedMovie'

class FeaturedMovies extends Component {

    componentDidMount() {

        this.props.fetchMovies()

    }

    render() {

        return (

            <div>

                <h2>{t('featured_movies')}</h2>

                <CardDeck>

                    {this.props.movies.map(movie => (

                        <FeaturedMovie key={movie.id} movie={movie} />

                    ))}

                </CardDeck>

            </div>

        )

    }

}

export default connect(

    state => ({ movies: state.movies.featured }),

    { fetchMovies }

)(FeaturedMovies)

A CardDeck is a presentational Bootstrap component that helps lay out a set of Cards. Luckily, our FeatureMovie component is wrapped in just such a Card.

/src/components/FeaturedMovie.js

import React from 'react'

import {

    Card,

    CardImg,

    CardBody,

    CardText,

    CardTitle,

} from 'reactstrap'

import { t } from '../services/i18n'

import LocalizedLink from '../containers/LocalizedLink'

function synopsis (str, length = 250) {

    const suffix = str.length > length ? '…' : ''

    return (str.substring(0, length) + suffix).split("\n\n")

}

export default function ({ movie }) {

    return (

        <Card style={{ marginBottom: "20px" }}>

            <LocalizedLink to={`/movies/${movie.id}`}>

                <CardImg top src={movie.thumbnail_url} alt={movie.title} />

            </LocalizedLink>

            <CardBody>

                <CardTitle>

                    <LocalizedLink to={`/movies/${movie.id}`}>

                        {movie.title}

                    </LocalizedLink>

                </CardTitle>

                <CardText className="small text-muted">

                    {t('directed_by', { director: movie.director.name })}

                    {' | '}

                    {t('published_on_date_only', { date: new Date(movie.published_on) })}

                </CardText>

                <CardText tag="div">

                    {synopsis(movie.synopsis).map((para, i) => (

                        <p key={i}>{para}</p>

                    ))}

                </CardText>

            </CardBody>

        </Card>

    )

}

The synposis helper function truncates a movie synopsis that's too long for our index view and returns an array of paragraphs. Other than that, we're just using our good old LocalizedLink to render links to the individual movie in the index, and t()ing up all our text, with interpolation where needed.

The rest of the views are, for our purposes, largely more of the same. So, I'll let you peer into the GitHub repo yourself to check them out.

When all is said and done, we get something that works a little something like this.

Finished and localized demo app | Phrase

We quickly run to our client to show her our finished front-facing proof of concept, with routing, language switching, internationalized UI, and localized content. We think we glean the beginnings of a smile on her face.

Writing code to localize your app is one task, but working with translations is a completely different story. Many translations for multiple languages may quickly overwhelm you which will lead to the user’s confusion. Fortunately, the Phrase Localization Suite can make your life as a developer easier.

I hope this gets you started on the right foot when building your React SPAs with i18n and l10n, and I hope it was as fun for you to read as it was for me to write. Be sure to check out the code and the live demo of the admin and public apps. Til next time 😊 👍🏽