Software localization

Localizing StimulusJS Applications with I18next

Learn how to localize your StimulusJS application with the help of the popular I18next library. We'll talk about controllers, targets and actions and you'll learn how to load translations asynchronously, add support for pluralization, detect preferred language and translate the whole page in one go.
Software localization blog category featured image | Phrase

There are surely lots of JavaScript frameworks out there these days: React, Angular, Vue, Aurelia and many others. All of them are viable and all of them have their fans and, unfortunately, haters. Today, however, I'd like to introduce you a new player: the StimulusJS framework. This is a small and modest JS framework that you can start using nearly right away, without the need of spending hours and hours studying its documentation. It has some interesting concepts and is pretty different from other well-known solutions. I started using it not long ago and really liked its ideas, so I recommend you giving it a shot too!

In this article we will create our first Stimulus application together and see how it can be localized with the help of I18next—a powerful internationalization library. We will discuss all the main concepts of Stimulus, including controllers, targets and actions. Also, you will learn how to load translations asynchronously, add support for pluralization, detect preferred language and translate the whole page in one go. By the end of this article you will get a nice overview of the Stimulus framework and hopefully will be eager to try and use it in one of your next projects!

The source code for this article can be found on GitHub. We will be using Stimulus version 1.0.1 that was released some days ago.

🔗 Resource » Learn everything you need to make your JS apps accesible to international users with our Ultimate Guide to JavaScript Localization and get lots of insights on frameworks like React, Angular and Vue.

Another JS Framework... ?

Well, yeah. Another JS framework. Don't give me that look—it's quite cool actually (I know they say this about each and every new framework out there). First and foremost thing to note: Stimulus is not meant to be a React or Angular clone, as it has very different philosophy. Stimulus is a modest framework designed to be simple and easily understandable. The thing is that for many projects using something like Angular might be overkill: you wish to purchase a tiny kitten figurine but they offer you a huge statue of Napoleon (even on a horse, perhaps).

This framework was created by the guys from Basecamp led by David Heinemeier Hansson, a brilliant developer who brought Ruby on Rails for us. Actually, some concepts of the framework will sound very familiar to developers who came from Rails world. You may read more about the Stimulus' origins here, but all in all everything revolves around three main concepts:

  • Controllers—basically, that's a class with some methods and attributes.
  • Actions—the actual method that is fired on some event (for example, when a button is clicked).
  • Targets—important elements in scope of the current controller. These elements can be easily accessed and modified later.

We are going to work with all these components in this article, so I won't cover them in detail here.

Some developers may find Stimulus' paradigm strange or inconvenient (but I really liked it). For example, in contrast to other popular frameworks, it attaches itself to existing HTML and stores state right inside this HTML (with the help of data- attributes). Of course, we are free to create new elements on the fly, but in many cases you wouldn't need to do that. You just have some markup and a code that works with it. This idea is really simple and you will get a grasp of Stimulus' basics in no time. To get started, there is a small handbook already available on GitHub, and I really recommend skimming through it.

Okay, enough with introductions. Let's dive into our today's code and create a simple application powered by Stimulus and I18next!

Bootstrapping a New Application

What are we going to build today? We will have a single page application with three main sections:

  • Language switcher
  • Application's title
  • List of new messages (it does not really matter what kind of messages)

The title and information about the incoming messages should be translated into either English or Russian. The user should be able to switch language on the fly. Also, the preferred locale should be set automatically based on the GET param, data from navigator or based on the lang attribute of the html tag. This seems like a lot of work, but it reality thing are quite simple. Let's start with creating the application.

Stimulus supports various build systems and also can be loaded in a script tag, so you may choose any option that works for you. Still, in this article we are not going to install everything manually. Instead, let's take advantage of the starter pack that already has all the necessary dependencies listed and configured. Alternatively, you may remix this starter pack on Glitch and play with the code in the browser without installing anything locally. If you do want to work locally, run the following commands:

git clone https://github.com/stimulusjs/stimulus-starter.git

cd stimulus-starter

yarn install

yarn start

This project relies on Yarn package manager so be sure to install it before doing anything else.

Now you may visit http://localhost:9000 where your first Stimulus application is located. What goodies does this app offer? Well, it contains the following dependencies:

Of course, you may configure these components further by modifying the corresponding files, but we won't need to do that. The first file that we are interested in is called scr/index.js. Inside you will find the following code:

import { Application } from "stimulus"

import { definitionsFromContext } from "stimulus/webpack-helpers"

const application = Application.start()

const context = require.context("./controllers", true, /\.js$/)

application.load(definitionsFromContext(context))

Basically, it requires all the controllers from the controllers folder and loads the Stimulus application. Initially, we have one empty controller called src/controllers/hello_controller.js:

import { Controller } from "stimulus"

export default class extends Controller {

}

We won't be using this controller so you may safely remove the file. Next, create a new controller called src/controllers/messages_controller.js:

// src/controllers/messages_controller.js

import { Controller } from "stimulus"

export default class extends Controller {

  connect() {

    console.log('hi!')

  }

}

It is going to asynchronously render our messages on the page. connect() is a special lifecycle callback method that gets called once the controller is connected to the DOM. As long as the same controller may be connected to various parts of the DOM multiple times, this callback may be run multiple times as well. There is also initialize() (runs only once, when the controller is instantiated) and disconnect() (runs when the controller is disconnected from the DOM) callback, so we do have some options.

Lastly, let's connect our controller to some element. This is as simple as adding an HTML tag with the data-controller set to messages. Our markup should be placed into the public/index.html file so modify it like this:

<!-- ... -->

<body>

<div class="messages" data-controller="messages"></div>

</body>

Reload the page and you should see "hi!" in the console which means that everything is working fine!

Before proceeding to the next section let's also add an h1 tag to the page. It is going to contain our application's name:

<!-- public/index.html -->

<!-- ... -->

<body>

<h1>Mailbox</h1>

<div class="messages" data-controller="messages"></div>

</body>

Rendering Messages

So far so good. Our controller works so let's load and render some messages. Start by modifying the connect() callback:

// src/controllers/messages_controller.js

connect() {

  this.loadMessages()

}

In a real application, messages will probably be fetched from a database and arrive in JSON format. As a first iteration, let's mimic this behavior by hard-coding messages right inside the controller:

// src/controllers/messages_controller.js

loadMessages() {

    const messages = [

      {

        "topic": "hi!",

        "body": "Good day, my friend :)"

      },

      {

        "topic": "important!",

        "body": "You won $1,000,000! Contact me asap!"

      }

    ]

    this.renderMessages(messages)

  }

Each message has two attributes: topic and body. We then take the messages array and pass it to the renderMessages method. Code it now:

// src/controllers/messages_controller.js

renderMessages(messages) {

    let content = `<p>You have ${messages.length} message(s)</p>`

    messages.forEach((msg) => {

      content += `<div class="message"><strong>${msg.topic}</strong><br>${msg.body}</div>`

    })

    this.element.innerHTML = content

  }

We display how many messages there are (the text is pretty clunky but it will be fixed later) and render each of them in a separate div. this.element in this case corresponds to the tag that has controller attached to.

Reload the page to make sure that both messages are displayed properly. This is nice, but we can do better. Let's employ an asynchronous request to load our messages!

Asynchronous Loading

There is no need to set up a real database and populate it with our messages, so instead I am going to create a new file public/messages.json. It is going to store the same contents as the messages variable:

[

  {

    "topic": "hi!",

    "body": "Good day, my friend :)"

  },

  {

    "topic": "important!",

    "body": "You won $1,000,000! Contact me asap!"

  }

]

How do we load this JSON now? Introduce a new data-messages-url attribute that is going to point to the URI where the messages can be fetched:

<div class="messages" data-controller="messages" data-messages-url="/messages"></div>

Now re-write loadMessages like so:

// src/controllers/messages_controller.js

loadMessages() {

    fetch(`${this.data.get("url")}.json`)

    .then(response => response.text())

    .then(json => {

      this.renderMessages(json)

    })

  }

Here a fetch request is sent to the given URI (we are appending .json inside the method because theoretically, our server may respond with various formats). Then two promises are set: one to get the response's body and another one to call our renderMessages method. This method requires some changes too:

// src/controllers/messages_controller.js

renderMessages(raw_messages) {

    const messages = JSON.parse(raw_messages)

    let content = `<p>You have ${messages.length} message(s)</p>`

    messages.forEach((msg) => {

      content += `<div class="message"><strong>${msg.topic}</strong><br>${msg.body}</div>`

    })

    this.element.innerHTML = content

  }

The main change is the JSON.parse call that parses the response and assigns the object to the messages constant. Apart from that, the code is the same.

Give it a try by reloading the page. If you open the Network tab under the Developer's Tools, you'll note that a separate GET request is sent to /messages.json meaning that our asynchronous loading works!

Adding Language Switcher

Our application has some basic functionality and we can start digging towards I18n features. The first thing I'd like to do is create a language switcher component powered by a separate Stimulus controller. Add the following markup to the public/index.html file:

<ul data-controller="locale" class="lang-changer"></ul>

Next, create a new src/controllers/locale_controller.js file:

import { Controller } from "stimulus"

export default class extends Controller {

  initialize() {

  }

}

We are going to have only one locale switcher on the page, so I'd like to take advantage of the initialize() callback that runs once.

As for the languages, I am going to add support for Russian and English but you, of course, may choose other locales as well. Let's dynamically render two switchers:

// src/controllers/locale_controller.js

initialize() {

    let languages = [

      {title: 'English', code: 'en'},

      {title: 'Русский', code: 'ru'}

    ]

    this.element.innerHTML = languages.map((lang) => {

      return `<li data-action="click->locale#changeLang"

      data-lang="${lang.code}">${lang.title}</li>`

    }).join('')

  }

Here you can see that a new data-action is introduced. It specifies which action should be called and on what condition. It can be read like this: "call a changeLang method from the LocaleController whenever this element is clicked". Also, we are providing the locale's code as a separate data-lang attribute.

Now the changeLang method:

// src/controllers/locale_controller.js

  changeLang(e) {

    this.currentLang = e.target.getAttribute("data-lang")

  }

e is the actual event, and e.target returns the node on which this event happened. Here we are simply grabbing the language's code that should be set and store it in the currentLang attribute.

Next I propose creating a special setter for the currentLang attribute that is going to store the new value under a data- attribute and also highlight a currently chosen locale:

// src/controllers/locale_controller.js

set currentLang(lang) {

    if(this.currentLang !== lang) {

      this.data.set("currentLang", lang)

      this.highlightCurrentLang()

    }

  }

this.data is a shorthand provided by Stimulus. It is the same as writing this.element.getAttribute.

Also we'll need a getter to read the data- attribute:

// src/controllers/locale_controller.js

get currentLang() {

    return this.data.get("currentLang")

  }

Now, what about the highlightCurrentLang? The easiest way to highlight one of our switchers is set a special class for it, but how do we access these nodes? To do that, we can utilize targets. Targets can be introduced in a very simple manner as well. Add the following line to the LocaleController:

// src/controllers/locale_controller.js

static targets = [ "changer" ]

// our callback and other methods...

And then assign a data-target attribute to the li generated on the fly:

// src/controllers/locale_controller.js

initialize() {

    let languages = [

      {title: 'English', code: 'en'},

      {title: 'Русский', code: 'ru'}

    ]

    this.element.innerHTML = languages.map((lang) => {

      return `<li data-action="click->locale#changeLang"

      data-target="locale.changer" data-lang="${lang.code}">${lang.title}</li>` // <---

    }).join('')

  }

This is it. Now we may say this.changerTarget to gain access to the first li or this.changerTargets to fetch them all. Quite an interesting solution.

Having this in place, utilize toggle to add or remove the current class for our switchers:

// src/controllers/locale_controller.js

highlightCurrentLang() {

    this.changerTargets.forEach((el, i) => {

      el.classList.toggle("current", this.currentLang === el.getAttribute("data-lang"))

    })

  }

So, the switcher will be assigned with a current class if its data-lang equals to the currently set language.

Add some basic styles to the public/main.css file to make the currently selected switcher underlined:

.lang-changer li {

  cursor: pointer;

}

.lang-changer .current {

  text-decoration: underline;

  cursor: default;

}

That's it! Once again reload the page and try clicking on the switchers. Make sure they are being highlighted properly.

Integrating I18next

We have prepared the ground and it is time to start integrating I18next library into our application. Run the following command:

yarn add i18next i18next-xhr-backend loc-i18next i18next-browser-languagedetector

It is going to install the following packages:

We are going to store I18n configuration in a separate src/i18n/config.js file:

import i18next from 'i18next'

import locI18next from 'loc-i18next'

import LngDetector from 'i18next-browser-languagedetector'

import I18nXHR from 'i18next-xhr-backend'

const i18n = i18next.use(LngDetector).use(I18nXHR).init({

  fallbackLng: 'en',

  whitelist: ['en', 'ru'],

  preload: ['en', 'ru'],

  ns: 'global',

  defaultNS: 'global',

  fallbackNS: false,

  debug: true,

  detection: {

    order: ['querystring', 'navigator', 'htmlTag'],

    lookupQuerystring: 'lang',

  },

  backend: {

    loadPath: '/i18n/{{lng}}/{{ns}}.json',

  }

}, function(err, t) {

  if (err) return console.error(err)

});

export { i18n as i18n }

What is going on here?

  • We are adding support for LanguageDetector and XHRBackend
  • Next, whitelisting Russian and English locale and ask them to be preloaded
  • Adding a namespace called global (you may have as many namespaces as needed)
  • Next, we are providing configuration for the LanguageDetector plugin. Specifically, it will try to use the ?lang param, value from the navigator or from the lang attribute of the html tag
  • After that we are saying where the translation files are located
  • Lastly, we are returning an error if something goes wrong

Now create two new folders and two new files under the public directory:

  • en
    • global.json
  • ru
    • global.json

Contents of the ru/global.json:

{

  "appTitle": "Моя почта",

  "messages_count_0": "У вас {{count}} сообщение",

  "messages_count_1": "У вас {{count}} сообщения",

  "messages_count_2": "У вас {{count}} сообщений"

}

en/global.json:

{

  "appTitle": "My Mail",

  "messages_count": "You have one message",

  "messages_count_plural": "You have {{count}} messages"

}

English has simpler pluralization rules, so it is okay to provide only two keys for the message count. You may utilize this small application to understand which keys should be specified for a specified language.

Changing Locale

Configuration is done and we may update our LocaleController. Import the I18next instance:

// src/controllers/locale_controller.js

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

We also need to utilize a special loaded callback because translations will take some time to load up and I don't want to allow switching languages before that happens:

// src/controllers/locale_controller.js

initialize() {

    i18n.on('loaded', (loaded) => { // <---

      let languages = [

        {title: 'English', code: 'en'},

        {title: 'Русский', code: 'ru'}

      ]

      this.element.innerHTML = languages.map((lang) => {

        return `<li data-action="click->locale#changeLang"

        data-target="locale.changer" data-lang="${lang.code}">${lang.title}</li>`

      }).join('')

      this.currentLang = i18n.language // <---

    }) // <--

  }

Note that we are alsi using the language method to grab the currently used locale and set it to currentLang.

The currentLang setter also should be changed:

// src/controllers/locale_controller.js

set currentLang(lang) {

    if(i18n.language !== lang) { // <---

      i18n.changeLanguage(lang) // <---

      window.history.pushState(null, null, `?lang=${lang}`) // <---

    }

    if(this.currentLang !== lang) {

      this.data.set("currentLang", lang)

      this.highlightCurrentLang()

    }

  }

If the requested locale is not the same as the currently used one, we call the changeLanguage method and also modify the lang GET param with the help of HistoryAPI.

Boot the server again and reload the root page of our application. In the console you should see the following output:

i18next::backendConnector: loaded namespace global for language en

Object { appTitle: "My Mail", messages_count: "You have one message", messages_count_plural: "You have {{count}} messages" }

i18next::backendConnector: loaded namespace global for language ru

Object { appTitle: "Моя почта", messages_count_0: "У вас {{count}} сообщение", messages_count_1: "У вас {{count}} сообщения", messages_count_2: "У вас {{count}} сообщений" }

i18next: languageChanged en

i18next: initialized

Object { debug: true, initImmediate: true, ns: […], defaultNS: "global", fallbackLng: […], fallbackNS: false, whitelist: […], nonExplicitWhitelist: false, load: "all", preload: […], … }

This means that the translations were preloaded and the language was set to English. If you navigate to localhost:9000?lang=ru, the third line should be "i18next: languageChanged ru". Also note that after clicking on the switchers, the ?lang GET param changes its value. Awesome!

Updating Translations

We've done a very good job, but our application is still not localized. We can, of course, utilize the i18n.t method to provide translation for each element on the page separately, but this is too tedious. To make things more simpe, we've installed the loc-i18next plugin that relies on data- attributes and can translate the whole page in one go. Stimulus also intrsucts us to persist state in data- attributes so this plugin looks suitable for us!

Let's configure and export it:

// src/i18n/config.js

// your previous config

const localize = locI18next.init(i18n, {

  selectorAttr: 'data-i18n',

  optionsAttr: 'i18n-options',

  useOptionsAttr: true

});

export { localize as localize, i18n as i18n }

The idea of this plugin is that translation keys should be specified under data- attributes (data-i18n) in this case. We then may say localize('body') and all elements will contain the proper translations. Really convenient.

Provide translation for the application's title:

<!-- public/index.html -->

<h1 data-i18n="appTitle"></h1>

Import localize and i18n:

// src/controllers/locale_controller.js

import { localize, i18n } from '../i18n/config'

Now intruct to update all translations whenever the locale is changed:

// src/controllers/locale_controller.js

 set currentLang(lang) {

    if(i18n.language !== lang) {

      i18n.changeLanguage(lang)

      window.history.pushState(null, null, `?lang=${lang}`)

    }

    if(this.currentLang !== lang) {

      this.data.set("currentLang", lang)

      localize("body"); // <---

      this.highlightCurrentLang()

    }

  }

The problem, however, is that we have some asynchronous stuff going on in the MessagesController, so when the messages are loaded, we should perform localization as well:

// src/controllers/messages_controller.js

import { Controller } from "stimulus"

import { localize } from '../i18n/config' // <---

export default class extends Controller {

  connect() {

    this.loadMessages()

  }

  loadMessages() {

    fetch(`${this.data.get("url")}.json`)

    .then(response => response.text())

    .then(json => {

      this.renderMessages(json)

      this.applyLocalization() // <---

    })

  }

  renderMessages(raw_messages) {

    const messages = JSON.parse(raw_messages)

    let content = `<p data-i18n="messages_count" i18n-options="{ 'count': ${messages.length} }"></p>` // <---

    messages.forEach((msg) => {

      content += `<div class="message"><strong>${msg.topic}</strong><br>${msg.body}</div>`

    })

    this.element.innerHTML = content

  }

  applyLocalization() { // <---

    localize('.messages') // <---

  } // <---

}

Here I also had to provide i18n-options for the p tag (that says how many messages we have).

And... that's it! Try switching between languages and observe how the application's title and messages count are being updated nearly instantly.

Phrase and Translation Files

Working with translation files can be challenging, especially when your app is of bigger scope and supports many languages. You might easily miss some translations for a specific language, which can lead to confusion among users.

And so Phrase can make your life easier: Grab your 14-day trial today. Phrase supports many different languages and frameworks, including JavaScript of course. It allows you to easily import and export translation data. What’s even greater, you can quickly understand which translation keys are missing because it’s easy to lose track when working with many languages in big applications.

On top of that, you can collaborate with translators as it’s much better to have professionally done localization for your website.

Conclusion

In this article, we've seen StimulusJS in action and understood that it is a really simple and easy-to-use framework. We have learned how to use controllers, how to create actions, and work with targets. Also, we've made part of our application asynchronous. On top of that, we've added support for I18next, localized our application, and made it possible to switch languages on the fly. Not bad for one day!

So, I really hope you liked Stimulus and are willing to play more with it because this framework seems really interesting and promising. Of course, it hasn't yet received much traction and does not have a large community, but as time goes this situation will surely change.