Internationalization in Plain React.js

Coding for a multilingual application is always challenging. Most of the time you end up adding some third-party library. Using basic React you can...

Featured on Hashnode
Internationalization in Plain React.js

While working in any MNC, You have to support customers from all around the world. Your app must support multi-lingual features. And The user should be able to switch between languages. Most of the time you choose third-party libraries like i18next. However, It comes will its own challenges.

Opinionated

In this article, I will focus on creating a simple and easy to use module for Internationalization that also uses very few lines of code. I will not compare why you should or should not use third-party libraries.

Before moving forward, Let's understand the requirement of the library.

Project Requirements

Project Requirements

  1. Users should be able to toggle between languages
  2. Users should see different text when a user switches to another language.

Non-Functional requirement

  1. Assuming we are using React.js, Library should support the latest hook concept of React.js
  2. The library should provide an option to update translations on runtime(fetch over the network)
  3. The library should provide support for default messages, In case if requested the key/value is not present in the set of messages
  4. The library should provide a method to update the default locale based on user preference
  5. Developers should be able to get messages in any locale, even though the default local is different. This will help to get messages in different languages at the same time.

Sample App:

Final App

Creating a react app

Creating a react app is pretty much simple. You can use create-react-app to create react app. If you already have your own app. You can skip this step and move to the next step.

npx create-react-app my-multi-lingual-app --template typescript

Once App is created, You can remove existing code from App.tsx.

/// src/App.tsx

import "./App.css";

const Home = () => {
  return null;
};
function App() {
  return (
    <div>
      <Home />
    </div>
  );
}

export default App;

Note: For this article, We will write code using Typescript. I always recommend using TypeScript for custom build libraries. Not only it reduces the efforts of explaining huge codes but it also provides better support by complimenting the bad parts of JS. You can read this article to find more reasons why I’m Not Using JavaScript Anymore.

Creating an i18n Package

For simplicity, let us create a directory inside the src directory. There are plenty of articles available online like How to create node.js modules. You can follow any of them to create a separate package and publish your library if you wish to do so.

Create a file Internationalization.tsx

/// src/i18n/Internationalization.tsx

import React from "react";

export const I18nProvider: React.FC<{}> = ({ children }) => {
  return <div> {children}</div>;
};

Let's import this and create a basic Application structure with default English text.

Update HTML template for the Application

import "./App.css";
import { I18nProvider } from "./i18n/internationalization";

const Home = () => {
  const onLangChange = (code: string) => {
    // toggle Language here
  };
  return (
    <div className="App">
      <header className="App-header">
        <div className="Controller">
          <button className="Button" onClick={() => onLangChange("en-US")}>
            En
          </button>
          |<button className="Button" onClick={() => onLangChange("zh-CN")}>
            中文
          </button>
        </div>
        <p>Welcome, Hello in English</p>
        <p>This is default message: Message not found!</p>
      </header>
    </div>
  );
};
function App() {
  return (
    <I18nProvider>
      <Home />
    </I18nProvider>
  );
}

export default App;

Test I18nProvider class with default messages

For our Internationalization module, We will be using React Context API. To avoid direct exposure to the provider class, We will wrap the provider using the HOC component. You can read in detail here, How to create a wrapper class for Provider.

/// src/i18n/Internationalization.tsx

interface MessagesProp {
  [key: string]: { [key: string]: string };
}

const I18nContext = createContext<{
  messages: MessagesProp;
}>({
  messages: {},
});

export const I18nProvider: React.FC<{}> = ({ children }) => {
  const [messages, setMessages] = useState<any>({
    "en-US": { hello: "Hello in English" },
  });
  return (
    <I18nContext.Provider
      value={{
        messages,
      }}
    >
      {children}
    </I18nContext.Provider>
  );
};

To access the value from the provider class, Let's create a custom hook.

/// rest of the code

export const I18nProvider: React.FC<{}> = ({ children }) => {
  /// rest of the code
};

export const useI18n = () => useContext(I18nContext);

Update the App.tsx, To get the hardcoded messages

/// import custom hook
import { I18nProvider, useI18n } from "./i18n/internationalization";

const Home = () => {
  const { messages } = useI18n();
  const onLangChange = (code: string) => {
    // toggle Language here
  };
  return (
    <div className="App">
      <header className="App-header">
        {/* rest of the code */}
        {/* Direct access to message */}
        <p>Welcome, {messages["en-US"].hello}</p>
        <p>This is default message: Message not found!</p>
      </header>
    </div>
  );
};
function App() {
  return (
    <I18nProvider>
      <Home />
    </I18nProvider>
  );
}

export default App;

Fetch messages from the network

Once you refresh, You can see the hardcoded messages. Let's fetch messages from the network and set them as messages in I18nProvider. To do so we need to create a public method to context provide.

interface MessagesProp {
  [key: string]: { [key: string]: string };
}
const I18nContext = createContext<{
  messages: MessagesProp;
  setMessages: (messages: MessagesProp) => void;
  locale: string;
  setLocale: (locale: string) => void;
}>({
  messages: {},
  setMessages: () => {},
  locale: "en-US",
  setLocale: () => {},
});

export const I18nProvider: React.FC<{ defaultLocale?: string }> = ({
  children,
  defaultLocale = "en-US",
}) => {
  const [messages, setMessages] = useState<any>({});
  const [locale, setLocale] = useState(defaultLocale);

  return (
    <I18nContext.Provider
      value={{
        messages,
        locale,
        setLocale,
        setMessages,
      }}
    >
      {children}
    </I18nContext.Provider>
  );
};

Similarly, we have to expose the method to set locale. Accessing all these methods inside App.tsx is pretty much simple. Let's update App.tsx.

import { useEffect } from "react";

import { I18nProvider, useI18n } from "./i18n/internationalization";

// virtual delay
const delay = () => new Promise((r) => setTimeout(r, 2000));

const Home = () => {
  const { messages, setMessages, setLocale, locale } = useI18n();

  const onLangChange = (code: string) => {
    // toggle Language here
  };
  useEffect(() => {
    Promise.all([
      fetch("/i18n/en-US.json").then((x) => x.json()),
      fetch("/i18n/zh-CN.json").then((x) => x.json()),
      delay(),
    ]).then(([enUS, zhCN]) => {
      setMessages({ "en-US": enUS, "zh-CN": zhCN });
    });
  }, [setMessages]);

  return <div className="App">{/* rest of the code */}</div>;
};
function App() {
  return (
    <I18nProvider>
      <Home />
    </I18nProvider>
  );
}

export default App;

Handle network delay

Even we fetch the message from the network and set it to messages, We will still see a broken page. This is because we try to access message in <p>Welcome, {messages["en-US"].hello}</p> without error handling. Due to a delay in the network(async), You will see this broken page. To avoid such issues, Or handle them elegantly. We can add a boolean(loaded) in our I18nProvider.

/// src/i18n/Internationalization.tsx

import React, { createContext, useContext, useState } from "react";

const I18nContext = createContext<{
  //rest of the code
  setLocale: (locale: string) => void;
  loaded: boolean;
}>({
  //rest of the code
  loaded: false,
});

export const isEmpty = (val: any) =>
  val == null || !(Object.keys(val) || val).length;

export const I18nProvider: React.FC<{ defaultLocale?: string }> = ({
  children,
  defaultLocale = "en-US",
}) => {
  //rest of the code

  return (
    <I18nContext.Provider
      value={{
        messages,
        locale,
        setLocale,
        setMessages,
        loaded: !isEmpty(messages),
      }}
    >
      {children}
    </I18nContext.Provider>
  );
};

export const useI18n = () => useContext(I18nContext);

Now update App.tsx to show loading messages after messages are fetched from the network.

import { I18nProvider, useI18n } from "./i18n/internationalization";

const Home = () => {
  const { messages, setMessages, setLocale, locale, loaded } = useI18n();

  const onLangChange = (code: string) => {
    // toggle Language here
  };
  ///rest of the code

  if (!loaded)
    return (
      <div className="App">
        <br />
        <br />
        <h2>Loading...</h2>
      </div>
    );
  return <div className="App">{/* rest of the code */}</div>;
};
function App() {
  return (
    <I18nProvider>
      <Home />
    </I18nProvider>
  );
}

export default App;

Toggle between languages

I18nProvider exposes setLocale method. Bypassing locale, We can get messages from I18nProvider. It will be much easier to handle messages in one language than having multiple languages. To get text from I18nProvider based on current locale, We can add translate a helper function/method.

/// src/i18n/Internationalization.tsx

import React, { createContext, useContext, useState } from "react";

const I18nContext = createContext<{
  //rest of the code
  loaded: boolean;
  translate: (
    key: string,
    defaultMessage?: string,
    locale?: string
  ) => string | undefined;
}>({
  //rest of the code
  loaded: false,
  translate: () => "",
});

export const isEmpty = (val: any) =>
  val == null || !(Object.keys(val) || val).length;

export const I18nProvider: React.FC<{ defaultLocale?: string }> = ({
  children,
  defaultLocale = "en-US",
}) => {
  //rest of the code

  const message = messages[locale];
  const translate = (key: string, defaultMessage?: string, locale?: string) => {
    return (locale ? messages[locale][key] : message[key]) || defaultMessage;
  };

  return (
    <I18nContext.Provider
      value={{
        messages,
        locale,
        setLocale,
        setMessages,
        loaded: !isEmpty(messages),
        translate,
      }}
    >
      {children}
    </I18nContext.Provider>
  );
};

export const useI18n = () => useContext(I18nContext);

Now getting locale text within the App.tsx will be much easier.

/// src/i18n/App.tsx

//rest of the code

const Home = () => {
  const { setMessages, translate, loaded, setLocale } = useI18n();

  const onLangChange = (code: string) => {
    // toggle Language here
    setLocale(code);
  };
  //rest of the code

  return (
    <div className="App">
      <header className="App-header">
        <div className="Controller">
          <button className="Button" onClick={() => onLangChange("en-US")}>
            En
          </button>
          |<button className="Button" onClick={() => onLangChange("zh-CN")}>
            中文
          </button>
        </div>
        <p>Welcome, {translate("hello")}</p>
        <p>This is default message: Message not found!</p>
      </header>
    </div>
  );
};
function App() {
  return (
    <I18nProvider>
      <Home />
    </I18nProvider>
  );
}

export default App;

Final code

Final code for src/i18n/App.tsx

/// src/i18n/App.tsx

import { useEffect } from "react";
import "./styles.css";
import { I18nProvider, useI18n } from "./i18n/internationalization";

// virtual delay
const delay = () => new Promise((r) => setTimeout(r, 2000));

const Home = () => {
  const { setMessages, translate, loaded, locale, setLocale } = useI18n();

  const onLangChange = (code: string) => {
    // toggle Language here
    setLocale(code);
  };
  useEffect(() => {
    Promise.all([
      fetch("/i18n/en-US.json").then((x) => x.json()),
      fetch("/i18n/zh-CN.json").then((x) => x.json()),
      delay(),
    ]).then(([enUS, zhCN]) => {
      setMessages({ "en-US": enUS, "zh-CN": zhCN });
    });
  }, [setMessages]);
  if (!loaded)
    return (
      <div className="App">
        <br />
        <br />
        <h2>Loading...</h2>
      </div>
    );
  return (
    <div className="App">
      <header className="App-header">
        <div className="Controller">
          <button className="Button" onClick={() => setLocale("en-US")}>
            En
          </button>
          |<button className="Button CN" onClick={() => setLocale("zh-CN")}>
            中文
          </button>
        </div>
        <p className={`message ${locale === "zh-CN" && "CN"}`}>
          Welcome, {translate("hello")}
        </p>
        <p>
          This is default message:
          {translate("no_message", "Message not found!")}
        </p>
      </header>
    </div>
  );
};
function App() {
  return (
    <I18nProvider>
      <Home />
    </I18nProvider>
  );
}

export default App;

Final code for src/i18n/Internationalization.tsx

/// src/i18n/Internationalization.tsx

import React, { createContext, useContext, useState } from "react";

interface MessagesProp {
  [key: string]: { [key: string]: string };
}

const I18nContext = createContext<{
  messages: MessagesProp;
  setMessages: (messages: MessagesProp) => void;
  locale: string;
  setLocale: (locale: string) => void;
  loaded: boolean;
  translate: (
    key: string,
    defaultMessage?: string,
    locale?: string
  ) => string | undefined;
}>({
  messages: {},
  setMessages: () => {},
  locale: "en-US",
  setLocale: () => {},
  loaded: false,
  translate: () => "",
});

export const isEmpty = (val: any) =>
  val == null || !(Object.keys(val) || val).length;

export const I18nProvider: React.FC<{ defaultLocale?: string }> = ({
  children,
  defaultLocale = "en-US",
}) => {
  const [messages, setMessages] = useState<any>({});
  const [locale, setLocale] = useState(defaultLocale);

  const message = messages[locale];
  const translate = (key: string, defaultMessage?: string, locale?: string) => {
    return (locale ? messages[locale][key] : message[key]) || defaultMessage;
  };

  return (
    <I18nContext.Provider
      value={{
        messages,
        locale,
        setLocale,
        setMessages,
        loaded: !isEmpty(messages),
        translate,
      }}
    >
      {children}
    </I18nContext.Provider>
  );
};

export const useI18n = () => useContext(I18nContext);

Demo Application

Source code: You can find the source code in the same code-sandbox, react-internationalization-screr.

Conclusion

We can clearly see by adding a few lines of code. We can create a basic simple Internationalization library. However, If your application is very big and you do not want to maintain your own custom library. You should use third-parties libraries. You will get better support and standardization.

Thanks, Hope you enjoy this article. It will give you a basic idea of how to use React.js context APIs. If you find this useful, Please share and react. If you have any queries and suggestions, kindly do write on comments.

Did you find this article valuable?

Support Deepak Vishwakarma by becoming a sponsor. Any amount is appreciated!