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...
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.
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
- Users should be able to toggle between languages
- Users should see different text when a user switches to another language.
Non-Functional requirement
- Assuming we are using React.js, Library should support the latest hook concept of React.js
- The library should provide an option to update translations on runtime(fetch over the network)
- The library should provide support for default messages, In case if requested the key/value is not present in the set of messages
- The library should provide a method to update the default locale based on user preference
- 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:
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.