Software localization
Roll Your Own i18n Solution with React and Redux
When writing a small React / Redux app, we may need a simple localization solution without the extra load of a third-party i18n library. Architecting our own solution can keep our code focused on exactly what we need, and gives us maximum control over the i18n library we’re building.
🔗 Resource » Check out our Ultimate Guide to JavaScript Localization for more insights on React and all the steps you need to make your JS apps accessible for users around the world.
Let’s assume we have a small app called Offen (German for “open”). Offen is a small community art app where artists can rate each other’s work and leave constructive criticism.
Our humble Offen
Offen was spun up with the official Redux template of Create React App. For the purposes of this article our app doesn’t do much; we want to focus on the i18n end of things.
🔗 Resource » Get the starter code of our demo app from GitHub.
We’ll assume that our community is multilingual and that we want to localize our app’s UI to any number of languages. Let’s say we just need basic string translation; we don’t need interpolation, plurals, or any fancy i18n features.
🔗 Resource » If you do want interpolation, plurals, date and number formatting, etc., then you might want to use a third-party i18n library. If you do, we got you covered with our React Redux Tutorial: Internationalization with react-i18n-redux.
Architectural considerations
A question that immediately arises when localizing a React/Redux app is: Should we build our i18n solution on top of Redux? We could use React Context instead of Redux and achieve similar results. Let’s weigh our options.
Using React Context could be favorable if we don’t see our localization as app state. We might see our i18n as belonging to the view layer, and not a concern of our state management layer (Redux). A React Context solution would make React the only dependency for our i18n library. We could swap out Redux for another state management implementation without touching our i18n code.
On the other hand, using Redux for our i18n solution gives us the great tooling and debug-ability that Redux always provides. And we could argue that the active locale, and whether or not the i18n system is loading, are bits of apps state, so they do belong in Redux.
At the end of the day, it almost doesn’t matter which implementation we choose, given that we architect our solution correctly. If we abstract our solution behind a good API, we can always change the implementation later. In this article, we’ll go with the Redux solution, and we’ll tuck the implementation behind a custom React hook.
Platforms and libraries used
For our demo app, we’re using the following libraries on top of Node 14.17:
- react@17.0.2 — our delicious view library
- react-redux@7.2.4 — official Redux API for Redux
- @reduxjs/toolkit@1.6.0 — makes Redux so much more pleasant to use (comes with the CRA Redux template)
- bulma@0.9.3 — CSS framework used for styling, just in case you were curious
- react-bulma-components@4.0.7 — React components for Bulma, again for styling
Configuration and our Redux slice
Let’s start by adding some basic i18n configuration.
export const defaultLang = "en"; export const supportedLangs = { en: "English", de: "Deutsche", fr: "Français", };
Our app will support three languages initially: English, German, and French. We’ll default our UI to English when the user first visits.
Now let’s use this config in a new Redux slice.
import { createSlice } from "@reduxjs/toolkit"; import { defaultLang, supportedLangs, } from "../../config/i18nConfig"; const initialState = { lang: defaultLang, // "en" when app loads supportedLangs: { ...supportedLangs }, // We'll keep our translations in Redux to start. // Later on we'll move them to their own files so // that they're easier to manage. translations: { en: { tagline: "Continuous improvement", ratings: "Ratings", }, de: { tagline: "Ständige Verbesserung", ratings: "Bewertungen", }, fr: { tagline: "Amélioration continue", ratings: "Évaluations", }, }, }; export const i18nSlice = createSlice({ name: "i18n", initialState, }); export const selectTranslations = (state) => state.i18n.translations[state.i18n.lang]; export default i18nSlice.reducer;
We make the configured default locale our initial one. We also expose the supported locales here: we’ll need them when we create a language selector shortly. For convenience, we export a selectTranslations
selector, which grabs the translations for the active locale.
Let’s not forget to wire up our slice to the global app store (that always bites me).
import { configureStore } from "@reduxjs/toolkit"; import i18nReducer from "../services/i18n/i18nSlice"; export const store = configureStore({ reducer: { i18n: i18nReducer, }, });
Using translations in our components
Let’s make use of selectTranslations
in our components.
import { useSelector } from "react-redux"; import { selectTranslations } from "../../services/i18n/i18nSlice"; // ... export default function Header() { const t = useSelector(selectTranslations); return ( <header> <!-- This is our brand name, so we want it hard-coded --> <Heading>Offen</Heading> <!-- Heading is just a presentational component --> <Heading> {t.tagline} </Heading> </header> ); }
t
is a common convention for a translations object, and we wire it up via the usual useSelector
hook. Notice that t.tagline
matches the key in the translations
object in our i18n
state.
Now if we change the configured default language and reload our app, we see our tagline translated to the current language.
Our header rendered per active locale
Building a language switcher
Our i18n system isn’t going to be very useful without a way for the user to pick their own UI language. So let’s add a nice LangSwitcher
component.
Of course, we’ll need a Redux reducer and action to change the active locale.
import { createSlice } from "@reduxjs/toolkit"; // ... export const i18nSlice = createSlice({ name: "i18n", initialState, reducers: { setLang: (state, action) => { state.lang = action.payload; }, }, }); export const { setLang } = i18nSlice.actions; // ...
✋🏽 Heads up » Redux Toolkit ensures that while
state.lang = action.payload
looks like it’s mutating state, it’s actually not. Under the hood Redux Toolkit uses the Immer library to make sure that new state is returned from the reducer ie. state is nice and immutable.
Ok, with the setLang
action and reducer in place, we have everything we need to build our LangSwitcher
.
import { Dropdown } from "react-bulma-components"; import { useSelector, useDispatch } from "react-redux"; import { setLang } from "../../services/i18n/i18nSlice"; // ... export function LangSwitcher() { // Inline selectors. We'll refactor this to a hook // later to make it clean and reusable. const lang = useSelector((state) => state.i18n.lang); const supportedLangs = useSelector( (state) => state.i18n.supportedLangs, ); const dispatch = useDispatch(); return ( <Dropdown // ... value={lang} onChange={(newLang) => dispatch(setLang(newLang))} > {Object.entries(supportedLangs).map( ([code, name]) => ( <Dropdown.Item value={code} key={code}> {name} </Dropdown.Item> ), )} </Dropdown> ); }
We’re controlling a Bulma Dropdown
here for styling, but we could have just as easily used a plain old <select>
. Spinning over the supportedLangs
, we output a dropdown item for each one. When an item is clicked, the onChange
event fires on the Dropdown
, and we dispatch
our setLang
action. This causes any component that depends on our translations
to re-render.
Let’s add our LangSwitcher
to our Header
.
import { useSelector } from "react-redux"; import { Heading, Columns } from "react-bulma-components"; import { selectTranslations } from "../../services/i18n/i18nSlice"; import { LangSwitcher } from "../../features/lang-switcher/LangSwitcher"; // ... export default function Header() { const t = useSelector(selectTranslations); return ( <header> <Columns> <Columns.Column> <Heading>Offen</Heading> <Heading> {t.tagline} </Heading> </Columns.Column> <Columns.Column> <LangSwitcher /> </Columns.Column> </Columns> </header> ); }
Et viola!
Translation files and async loading
We could get away with keeping our translations in our initial i18n state if our app remains this small. However, adding languages and getting translations from translators would be a pain as our app grows. Let’s separate our translations into per-locale JSON files. We could then load the translations file for the active local asynchronously when needed.
First let’s configure our translation file URL.
export const defaultLang = "en"; export const supportedLangs = { en: "English", de: "Deutsche", fr: "Français", }; export const langUrl = "/lang/{lang}.json";
Next let’s copy our translations into per-locale files that matches the configured URL.
{ "tagline": "Continuous improvement", "ratings": "Ratings" }
{ "tagline": "Ständige Verbesserung", "ratings": "Bewertungen" }
{ "tagline": "Amélioration continue", "ratings": "Évaluations" }
We’ll need an HTTP call to fetch our translations.
import { langUrl } from "../../config/i18nConfig"; export function fetchTranslations(lang) { return new Promise((resolve) => { fetch(langUrl.replace("{lang}", lang)) .then((response) => response.json()) .then((data) => resolve(data)); }); }
Alright, with that in place we can update our i18n slice to create an async thunk action that fetches and sets the translations.
import { createSlice, createAsyncThunk, } from "@reduxjs/toolkit"; import { defaultLang, supportedLangs, } from "../../config/i18nConfig"; import { fetchTranslations } from "./i18nAPI"; const initialState = { status: "loading", lang: defaultLang, supportedLangs: { ...supportedLangs }, // Notice that our translations have been removed // from initial state. translations: {}, }; export const setLangAsync = createAsyncThunk( "i18n/setLangAsync", async (lang, { getState, dispatch }) => { // Default to active locale if none is given. const resolvedLang = lang || getState().i18n.lang; const translations = await fetchTranslations( resolvedLang, ); dispatch(i18nSlice.actions.setLang(resolvedLang)); return translations; }, ); export const i18nSlice = createSlice({ name: "i18n", initialState, reducers: { setLang: (state, action) => { state.lang = action.payload; }, }, extraReducers: (builder) => { builder.addCase(setLangAsync.pending, (state) => { state.status = "loading"; }); builder.addCase( setLangAsync.fulfilled, (state, action) => { state.translations = action.payload; state.status = "idle"; }, ); }, }); export const selectTranslations = (state) => state.i18n.translations; export default i18nSlice.reducer;
setLangAsync
is a thunk that uses our new fetchTranslations
API to grab the translations for a given locale asynchronously. It then uses the fetched translations to hydrate or overwrite our translations
state. setLangAsync
will, of course, also set the lang
state to the given locale.
Notice that we’ve had to update our selectTranslations
selector so that it no longer refines into the translations
object; translations
will now be a flat dictionary of translations in the current locale only.
Here’s what our Redux state will look like after we switch our language to French and fetch the equivalent French translations.
{ i18n: { status: 'idle', lang: 'fr', supportedLangs: { en: 'English', de: 'Deutsche', fr: 'Français' }, translations: { tagline: 'Amélioration continue', ratings: 'Évaluations' } } }
You may have noticed the new status
. We’ll use this to track our async loading state. This way we’ll avoid errors from our components trying to access translations before they’ve loaded from the network.
import React, { useEffect } from "react"; import { useSelector, useDispatch } from "react-redux"; // ... import { setLangAsync } from "./services/i18n/i18nSlice"; // ... function App() { const i18nStatus = useSelector( (state) => state.i18n.status, ); const dispatch = useDispatch(); useEffect(() => dispatch(setLangAsync()), []); return i18nStatus === "loading" ? ( <p>Loading...</p> ) : ( <> <!-- Our app content goes here, and will only render after our translations have loaded --> </> ); } export default App;
When our app first renders, we make sure to displaying a “Loading…” message. We immediately dispatch the setLangAsync()
to fetch the translations of our default locale. As soon as the translations have loaded our i18nStatus
state will become "idle"
, which will cause our components to render and display our translations.
Our LangSwitcher
will need to use our new thunk action when setting the locale now.
import { Dropdown } from "react-bulma-components"; import { useSelector, useDispatch } from "react-redux"; import { setLangAsync } from "../../services/i18n/i18nSlice"; // ... export function LangSwitcher() { const lang = useSelector((state) => state.i18n.lang); // ... const dispatch = useDispatch(); return ( <Dropdown value={lang} onChange={(newLang) => dispatch(setLangAsync(newLang)) } > <!-- Render items --> </Dropdown> ); }
With that our translations are decoupled from our Redux code, and we only load the translations for the active locale.
Level up: You have achieved async loading
A custom useTranslations hook
It’s often helpful to hide the implementation details of any module we build. This allows us to refactor the module to our heart’s content in the future with little to no effect on the consuming code. Let’s restructure our current i18n solution, putting it behind a reusable custom React hook. We’ll call the hook useTranslations
.
import { useSelector, useDispatch } from "react-redux"; import { setLangAsync } from "./i18nSlice"; export default function useTranslations() { const dispatch = useDispatch(); const t = useSelector((state) => state.i18n.translations); const setLang = (lang) => dispatch(setLangAsync(lang)); const lang = useSelector((state) => state.i18n.lang); const supportedLangs = useSelector( (state) => state.i18n.supportedLangs, ); const status = useSelector((state) => state.i18n.status); return { t, lang, setLang, init: setLang, supportedLangs, status, }; }
We’re just placing all our selectors into the one hook function and exposing them in the returned object. We’re also abstracting the dispatching of our set/fetch translations behind a setLang
function. We alias setLang
to init
, since setLang
needs to be called to initialize the i18n system by loading our default translations.
Now we can refactor our components to useTranslations()
.
import React, { useEffect } from "react"; // ... import useTranslations from "./services/i18n/useTranslations"; // ... function App() { const { init, status: i18nStatus } = useTranslations(); useEffect(() => init(), []); return i18nStatus === "loading" ? ( <p>Loading...</p> ) : ( <> <!-- Our app content goes here, and will only render after our translations have loaded --> </> ); } export default App;
import { Dropdown } from "react-bulma-components"; import useTranslations from "../../services/i18n/useTranslations"; // ... export function LangSwitcher() { const { lang, supportedLangs, setLang } = useTranslations(); return ( <Dropdown value={lang} onChange={(newLang) => setLang(newLang)} > {Object.entries(supportedLangs).map( ([code, name]) => ( <Dropdown.Item value={code} key={code} > {name} </Dropdown.Item> ), )} </Dropdown> ); }
import { Heading, Columns } from "react-bulma-components"; // ... import useTranslations from "../../services/i18n/useTranslations"; // ... export default function Header() { const { t } = useTranslations(); return ( <header> <Columns> <Columns.Column> <!-- ... --> <Heading> {t.tagline} </Heading> </Columns.Column> <!-- ... --> </Columns> </header> ); }
Our app works exactly as it did before. But now we’ve encapsulated our i18n solution in the useTranslations()
hook, abstracting away our implementation details. We can grow our solution or change its implementation as we see fit without fear of disrupting the consuming code.
With all the translations in place, our saucy app is looking sauce-ay.
Don’t worry, Link lands on his feet
🔗 Resource » If you want the full code of the demo app, grab it from GitHub.
Further reading
For a Redux-less i18n solution with one of the top third-party libraries check out The Ultimate Guide to React Localization with i18next. Not convinced that i18next is for you? The Best Libraries for React I18n might help you choose one. And if you’re interested in i18n as it pertains to server-side rendering, there’s Localized Server-Side Rendering with React.
And if you want to level up your localization game, check out Phrase. Built by developers for developers, Phrase is your localization platform of choice, featuring a flexible CLI and API; GitHub, GitLab, and Bitbucket sync; machine translation, and much, much more. The platform is always growing to meet your needs, so let Phrase do the heavy localization work for you, and get back to the code you love (or playing Zelda). Try Phrase free for 14 days.