Roll Your Own i18n Solution with React and Redux

Avoid bloating your bundle with third-party i18n libraries.

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.

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.

We hope you’ve enjoyed this one. Would you like us to develop this bespoke i18n library further, maybe by adding interpolation, plurals, and more? Let us know in the comments below!

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.

5 (100%) 18 votes
Comments
close

Automate Your Localization Workflow for Continuous Deployment

Automate Localization for Continuous Deployment

  • Integrate Phrase into your agile environment easily
  • Import and export your localization files in any format
  • Automate your localization workflow to speed up every release