Internationalization (i18n; because there are 18 letters between "i" and "n"), is the process of preparing apps to support multiple languages and regional settings (i.e. "locale") to accommodate a global user base. So it's not just about translating words, but also about the formatting of dates, numbers and phone numbers.
It is one of those features product managers move back it to the backlog, because they know adding support for it is easy, but supporting it while the product is in development is a pain in the ass multiplied by the number of supported locales.
But here we are! And luckily Rails has great support for i18n out-of-the-box. But how does that extend to JavaScript and specifically, Stimulus? I want to explore multiple ways.
So far, in the editor_controller.js, there are three static strings, I'd like to be able to translate:
// …
const statuses = {
saved: "Saved",
saving: "Saving…",
failed: "Save failed"
}
export default class extends Controller {
// …
}
5.2.1 Use the HTML you already have
Stimulus' tagline is "a modest JavaScript framework for the HTML you already have". And with that you can render the, translated, content in the HTML like you normally do with Rails and then show/hide, inject and transform HTML-elements using Stimulus. Easy and straight-forward. No extra steps needed!
5.2.2 Using Stimulus' separate values API
If you need to do more DOM wrangling, you can choose to use the values API.
const statuses = {
saved: "Saved",
saving: "Saving…",
failed: "Save failed"
}
export default class extends Controller {
static values = {
// …
savedStatus: { type: String, default: statuses.saved },
savingStatus: { type: String, default: statuses.saving },
failedStatus: { type: String, default: statuses.failed }
}
// …
}
And then instead of using statuses.saved
, you can use the value like any other: this.savedStatusValue
. Perfect solution if you only have a few keys!
5.2.3 Using Stimulus' object values API
But when you have many keys (even the above three status keys I find too much already), you could pass them as an object instead. First make sure you add the keys to your locale config, e.g. config/locales/en.yml:
en:
hello: "Hello world"
status:
saved: Saved
saving: Saving
failed: Failed…
Then you can pass them to the controller like so:
<div
data-controller="editor"
data-editor-i18n-value="<%= t('status').to_json %>"
>
</div>
And instead of three separate values, you have one statuses object:
export default class extends Controller {
static values = {
// …
i18n: Object
}
// …
}
Then use it in the controller as this.i18nValue.saved
. Pretty sweet! This solution doesn't support default values, but if you need to pass multiple keys—not just status
—you can do so with some Ruby logic.
5.2.4 Custom JavaScript solution
These solutions all work pretty fine, but are limited to Stimulus controllers. What if you need some translated strings in an (imported) class or method? Pass those strings along as well?
What about using syntax like this:
const statuses = {
saved: t("status.saved"),
saving: t("status.saving"),
failed: t("status.failed")
}
I recognize that! That is just like the t (short for I18n.t) in Rails! It is not supported, like this, out-of-the-box. Let's add the t
method in app/javascript/controllers/helpers.js (and import it in editor_controller.js):
export function t(key) {
if (!window.i18n) return key
try {
return key.split(".").reduce(
(translation, part) => translation[part], window.i18n
)
} catch {
return key
}
}
This tiny block of code does quite a bit! First something you've seen before: an early return when window.i18n
is not available (it will be defined later).
Next, inside the try-block, a chain of operations is added:
return key.split(".").reduce(
(translation, part) => translation[part], window.i18n
)
It first splits a string, e.g. user.profile.name
into an array (exactly how you would do it in Ruby) and then there is the reduce
method again!
Then the t
method. If the logic in the try-block does not fail, its value will be returned. If does fail, i.e. the translated string is not there, the value of key
will be returned, e.g. user.profile.name
.
So what about window.i18n
where is that coming from? It is something you need to add. It will hold all the defined i18n-keys (from, for example, config/locales/en.yml). For simplicity, let's add it to the app/views/pages/show.html.erb.
<script>
window.i18n = <%= raw I18n.backend.send(:translations)[I18n.locale].to_json %>
</script>
You are a Ruby developer, so I don't have to explain what it does, but in short: it gets all translations from Rails' i18n and assign this JSON object to window.i18n
.
5.2.4.1 Window object
The window object is a global object in web browsers that represents the window or a tab in your browser. It allows for variables to be globally available:
window.i18n = "Whatever value"
console.log(i18n) // => Whatever value
It also provides built-in methods, like window.alert
, window.console.log
, window.location
and so on.
console.log(window.location) // => https://javascriptforrails.com/
console.log(window.innerWidth) // 1440
But you don't have to call window.console.log("Something")
or in general append the window
object. This you might already know. Maybe you didn't even knew console.log()
was the shorthand to write it at all. The reason I used window.i18n
in above t()
method is for clarity; to signify where it was defined.
if you write some method in app/javascript/application.js:
// app/javascript/application.js
window.someMethod = function() {
console.log("Hello from application.js")
}
The use-cases for global attributes are slim. And as a general rule, just like with Ruby (or Rails' CurrentAttributes), minimize using global variables.
5.2.4.2 Try-catch block
There is one more thing that is new in the t()
method and that is the try-catch block. It attempts to execute the code in the try block, and if any errors occur, it catches(pun!) them in the catch block. You can compare it to Ruby's begin…rescue
block.
In above t()
method, if the translation lookup fails, it simply returns the original key (e.g. user.profile.name
).
But, just like with Ruby's begin…rescue
as an ensure
keyword, JavaScript has a finally
keyword. Check out these two equivalent code:
begin
file = open_file("data.txt")
process_file(file)
rescue => error
puts "File processing failed: #{error}"
ensure
close_file(file)
end
try {
let file = openFile("data.txt")
processFile(file)
} catch (error) {
console.error("File processing failed:", error)
} finally {
closeFile(file)
}
No matter if there is an error or not, in both examples, close_file(file)
and closeFile(file)
are ran respectively.
5.2.5 Dates, time and currency internationalization
The Intl
object is a built-in i18n API that provides language-sensitive string comparison, number formatting, date and time formatting and so on.
Instead of manually formatting to, let's say the Dutch currency formatting € 42,42
, like so € ${currency} ${number.toFixed(2).replace(".", ",")}
you can do it like this:
const formatter = new Intl.NumberFormat("nl-NL", {
style: "currency",
currency: "EUR"
})
formatter.format(42.42) // => "€ 42,42"
You can also use it for dates:
const date = new Date()
const formatter = new Intl.DateTimeFormat("ar-EG")
formatter.format(date) // => "٢١/٣/٢٠٢٥"
You can also pass it a time zone:
const date = new Date()
const formatter = new Intl.DateTimeFormat("nl-NL", {timezone: "Europe/Amsterdam"})
formatter.format(date) // => "21-3-2025"
For optimization reasons, it is important to set the formatter once (e.g. in the controller's initialization). Or you can use the i18n class that is packaged with this book. It gives short-hand methods, like:
import { i18n } from "./helpers/i18n"
// (…)
i18n.number(1234.567) // "1.234,57"
i18n.currency(42.42) // "€ 42,42"
i18n.date(new Date()) // "21 maart 2025"
i18n.time(new Date()) // "14:30"
i18n.datetime(new Date()) // "21 maart 2025 14:30"
i18n.list(["A", "B", "C"]) // "A, B en C"
i18n.percentage(0.1234) // "12%"
See the latter chapter on explanation on how that class works.