Building an Awesome Magazine App with i18n in React!

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 i18n-ized prototypes—which we can turn into full on production apps. In this article we build two i18n-ized React / Redux SPAs: an admin back end and a front-facing app, with many of the react i18n and l10n bells and whistles that production apps would need. Coming along for the ride.

The movie magazine app

Let’s assume we’ve been commissioned to build a clean prototype for μveez, a new i18n-ized movie magazine app. Our client has asked that we build it as an 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

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.

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:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

/

├──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.

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.

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 i18n-ized 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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

import Home from'./components/Home'

import Movies from'./components/Movies'

import AddMovie from'./containers/AddMovie'

import Directors from'./components/Directors'

constroutes=[

{

path:"/",

exact:true,

component:Home

},

{

path:"/directors",

component:Directors

},

{

path:"/movies",

exact:true,

component:Movies

},

{

path:"/movies/new",

exact:true,

component:AddMovie

}

]

export defaultroutes

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 component. 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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

import React from'react'

import{Switch,Route,BrowserRouter asRouter}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.

Director CRUD

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

Note » As per as 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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

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)

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

[

{

"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 how our director list would look like.

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

import_from'lodash'

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

returnstate

}

}

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

1

2

3

4

5

6

7

8

9

10

11

export constfetchDirectors=()=>dispatch=>(

fetch('/api/directors.json')

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

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

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

)

export constaddDirectors=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 setup 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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

import{Table}from'reactstrap'

import{connect}from'react-redux'

import React,{Component}from'react'

import{fetchDirectors}from'../actions'

classDirectorListextendsComponent{

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 defaultconnect(

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)

1

2

3

4

5

6

7

8

9

10

11

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

1

2

3

4

5

6

7

8

9

10

11

12

13

// ...

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 for adding directors. While we’re in the directors reducer, let’s add a new action handler for tracking translation user input.

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 translations values to current state using the defaultOnUndefinedOrNull utility function. This function checks if its first parameter is undefined or null, and if it is 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 entered it.

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

/src/actions/index.js (excerpt)

1

2

3

4

5

6

7

8

9

10

// ...

export constsetNewDirector=({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.

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

import React from'react'

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

export defaultprops=>{

constdir=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 into 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)

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

// ...

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)

1

2

3

4

5

6

7

8

9

10

// ...

export constaddDirector=({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)

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

// ...

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

classAddDirectorextendsComponent{

// ...

_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>

<Forminline>

<AddDirectorTranslation.../>

{/* ... */}

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

</Form>

</CardBody>

</Card>

)

}

}

export defaultconnect(

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.

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.

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 i18n-ized 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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

export constdefaultLocale="en"

export constlocales=[

{

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

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'

constroutes=[

{

path:"/",

exact:true,

localize:false,

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

},

{

path: "/movies/:id",

component: SingleMovie

},

{

path: "/movies",

component: Movies

},

{

path: "/",

component:Home

}

]

export defaultlocalizeRoutes(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)

1

2

3

4

5

6

7

8

9

10

11

12

13

export functionlocalizeRoutes(routes){

returnroutes.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.

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

/src/containers/App.js

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

import React from'react'

import{connect}from'react-redux'

import{

Route,

Switch,

BrowserRouter asRouter

}from'react-router-dom'

import routes from'../routes'

import Localizer from'./Localizer'

import AppNavbar from'../components/AppNavbar'

import AppFooter from'../components/AppFooter'

constApp=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 defaultconnect(

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/.

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.

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)

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

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)

1

2

3

render(){

returnthis.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)

1

2

3

4

5

6

7

8

9

10

export defaultwithRouter(

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)

1

2

3

4

5

6

7

8

9

10

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

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

1

2

3

4

5

6

7

8

9

10

export defaultwithRouter(

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)

1

2

3

4

5

6

7

8

9

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 from.

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)

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

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)

1

2

3

4

5

6

7

8

9

10

11

12

13

14

export functionswitchHtmlLocale(locale,dir,opt={}){

consthtml=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.

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)

1

2

3

4

5

6

7

8

9

10

{

"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…

1

2

3

4

5

6

7

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)

1

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

import i18next from'i18next'

import{formatDate}from'./util'

// ...

i18next.init({

// ...

interpolation:{

format:function(value,format,locale){

if(value instanceofDate){

returnformatDate(value,format,locale)

}

returnvalue

}

}

}

// ...

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)

1

"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.

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

/public/translations/fr.json (excerpt)

1

2

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

export functionformatDate(value,format,locale){

constoptions={}

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

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

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

})

try{

returnnewIntl.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.

/src/components/AppNavbar.js

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

88

89

90

91

92

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'

classAppNavbarextendsComponent{

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>

<DropdownMenuright>

{locales.map(locale=>(

<Link

key={locale.code}

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

className="dropdown-item"

>

{locale.name}

</Link>

))}

</DropdownMenu>

</UncontrolledDropdown>

</Nav>

</Collapse>

</Navbar>

)

}

}

export defaultAppNavbar

Most of the components we’re using here are Bootstrap presentation 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 i18n-ized 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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

import{connect}from'react-redux'

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

import React,{Component}from'react'

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

classLocalizedLinkextendsComponent{

render(){

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

return(

<Link

className={className}

to={prefixPath(to,locale)}

>

{children}

</Link>

)

}

}

export defaultconnect(

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

1

2

3

4

5

6

7

8

9

10

11

<DropdownMenuright>

{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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

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. Once all is built out, we get this glorious rendering.

Banging prototype, dude 👾. 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)

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

[

{

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

constINITIAL_STATE={

movies:[],

featured:[],

}

constmovies=(state=INITIAL_STATE,action)=>{

switch(action.type){

case'ADD_MOVIES':

return{

...state,

movies:[...action.movies],

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

}

default:

returnstate

}

}

export defaultmovies

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)

1

2

3

4

5

6

7

8

9

10

11

12

13

export constfetchMovies=()=>(dispatch,getState)=>{

const{locale}=getState().l10n

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

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

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

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

}

export constaddMovies=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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

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'

classFeaturedMoviesextendsComponent{

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 defaultconnect(

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

import React from'react'

import{

Card,

CardImg,

CardBody,

CardText,

CardTitle,

}from'reactstrap'

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

import LocalizedLink from'../containers/LocalizedLink'

functionsynopsis(str,length=250){

constsuffix=str.length>length?'…':''

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

}

exportdefaultfunction({movie}){

return(

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

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

<CardImg topsrc={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:newDate(movie.published_on)})}

</CardText>

<CardText tag="div">

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

<pkey={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.

We quickly run to our client to show her our finished front-facing proof of concept, with routing, language switching, i18n-ized 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, PhraseApp can make your life as a developer easier! Feel free to learn more about PhraseApp, referring to the Getting Started guide.

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 😊 👍🏽

Published on February 26th, 2018 by Mohammad Ashour.
Last updated at March 15th, 2018 .