If you make an application which will be used all over the world, you probably want to handle internationalization for texts, dates and numbers.
It already exists libraries to do that like react-intl, LinguiJS or i18next. In this article we will do our own implementation which is similar to react-intl one.
React context
Before starting to code, it's important to know React context and understand its use.
Basically, it permits to put some data (object, callback, ...) in a Context which will be accessible through a Provider to all children component of this provider. It's useful to prevent props drilling through many components.
This code:
function App() {
return (
<div>
Gonna pass a prop through components
<ChildFirstLevel myProp="A prop to pass" />
</div>
);
}
function ChildFirstLevel({ myProp }) {
return <ChildSecondLevel myProp={myProp} />;
}
function ChildSecondLevel({ myProp }) {
return <ChildThirdLevel myProp={myProp} />;
}
function ChildThirdLevel({ myProp }) {
// Some process with myProp
// It's the only component that needs the props
return <p>This component uses myProp</p>;
}
Can become:
import { createContext, useContext } from "react";
const MyContext = createContext();
function App() {
return (
<MyContext.Provider value="A prop to pass">
<div>
Gonna pass a value with react context
<ChildFirstLevel />
</div>
</MyContext.Provider>
);
}
function ChildFirstLevel() {
return <ChildSecondLevel />;
}
function ChildSecondLevel() {
return <ChildThirdLevel />;
}
function ChildThirdLevel() {
const myProp = useContext(MyContext);
// Some process with myProp
// It's the only component that needs the props
return <p>This component uses myProp</p>;
}
This is just an example that would probably not exist in real application
For more information the React documentation is awesome.
I18n implementation
Creation of the Provider
The first step is to create the React context with the Provider which will provides our utilities callback in next parts. This provider will take in parameter the locale which will be used for the current user, which could be the value of navigator.language
for example.
import { createContext, useContext, useMemo } from "react";
const I18nContext = createContext();
const useI18nContext = () => useContext(I18nContext);
function I18nProvider({ children, locale }) {
const value = useMemo(
() => ({
locale,
}),
[locale]
);
return (
<I18nContext.Provider value={value}>
{children}
</I18nContext.Provider>
);
}
Note: I have memoized the value, because in the project some components could be memoized because having performance problem (costly/long render)
In the next parts we will add some utilities functions in the context to get our value in function of the locale
Translation messages
Implementation
For our example we will just do an object of translations by locale with locale. Translations will be values by key.
const MESSAGES = {
en: {
title: 'This is a title for the application',
body: 'You need a body content?'
},
fr: {
title: 'Ceci est le titre de l\'application',
body: 'Besoin de contenu pour le body?'
}
};
These translations will be passed to our Provider (but not put in the context).
In applications, these translations can be separated in file for specific language for example:
messages-fr.properties
,messages-en.properties
, ... Then you can build an object from these files ;)
Now let's implement the method to get a message from its key in the Provider:
// The messages are passed to the Provider
function I18nProvider({ children, locale, messages }) {
// The user needs to only pass the messageKey
const getMessage = useCallback((messageKey) => {
return messages[locale][messageKey];
}, [locale, messages]);
const value = useMemo(() => ({
locale,
getMessage,
}), [locale, getMessage]);
return (
<I18nContext.Provider value={value}>
{children}
</I18nContext.Provider>
);
}
It can happen that there is no translation in the current locale (maybe because you do translate messages from a specific enterprise). So it can be useful to give a defaultLocale
to fallback to with locale and/or a defaultMessage
. The Provider becomes:
// Pass an optional defaultLocale to the Provider
function I18nProvider({
children,
locale,
defaultLocale,
messages,
}) {
// Fallback to the `defaultMessage`, if there is no
// defaultMessage fallback to the `defaultLocale`
const getMessage = useCallback(
({ messageKey, defaultMessage }) => {
return (
messages[locale]?.[messageKey] ??
defaultMessage ??
messages[defaultLocale][messageKey]
);
},
[locale, messages, defaultLocale]
);
const value = useMemo(
() => ({
locale,
getMessage,
}),
[locale, getMessage]
);
return (
<I18nContext.Provider value={value}>
{children}
</I18nContext.Provider>
);
}
Get a message value
There is multiple possibilities to get a message:
- get the function
getMessage
withuseI18nContext
const { getMessage } = useI18nContext();
const title = getMessage({ messageKey: 'title' });
- implements a component
I18nMessage
that hasmessageKey
anddefaultMessage
function I18nMessage({ messageKey, defaultMessage }) {
const { getMessage } = useI18nContext();
return getMessage({ messageKey, defaultMessage });
}
// Use
<I18nMessage messageKey="title" />
- implements an HOC
withI18n
that injectsgetMessage
to our component
function withI18n(WrappedComponent) {
const Component = (props) => {
const { getMessage } = useI18nContext();
return (
<WrappedComponent
{...props}
getMessage={getMessage}
/>
);
};
Component.displayName = "I18n" + WrappedComponent.name;
return Component;
}
function Title({ getMessage }) {
const title = getMessage({ messageKey: "title" });
return <h1>title</h1>;
}
const I18nConnectedTitle = withI18n(Title);
Dates handling
Ok, now let's handle Date formatting. In function of the country (or locale) a date does not have the same displayed format. For example:
// Watch out the month is 0-based
const date = new Date(2021, 5, 23);
// In en-US should be displayed
"6/23/2021"
// In fr-FR should be displayed
"23/06/2021"
// In en-IN should be displayed
"23/6/2021"
Note: The time is also not formatted the same way in function of the locale
To implements this feature, we are gonna use the Intl.DateTimeFormat
API which is accessible on all browsers.
Note: Intl relays on the
Common Locale Data Repository
Implementations
For the implementation we are gonna expose to the user the possibility to use all the option of the Intl API for more flexibility.
The previous I18nProvider
becomes:
function I18nProvider({
children,
locale,
defaultLocale,
messages,
}) {
const getMessage = useCallback(
({ messageKey, defaultMessage }) => {
return (
messages[locale]?.[messageKey] ??
defaultMessage ??
messages[defaultLocale][messageKey]
);
},
[locale, messages, defaultLocale]
);
const getFormattedDate = useCallback(
(date, options = {}) =>
Intl.DateTimeFormat(locale, options).format(date),
[locale]
);
const value = useMemo(
() => ({
locale,
getMessage,
getFormattedDate,
}),
[
locale,
getMessage,
getFormattedDate,
]
);
return (
<I18nContext.Provider value={value}>
{children}
</I18nContext.Provider>
);
}
Note: I will not describe the usage, because it's exactly the same as translations but you can see the final implementation in the codesandbox in conclusion.
Number format handling
If you want to manage numbers, price, ... in your project, it can be useful to format these entities in the right one not to disturb users.
For example:
- separator symbol is not the same
- the place and the symbol of the currency can be different
- ...
const number = 123456.789;
// In en-US should be displayed
"123,456.789"
// In fr-FR should be displayed
"123 456,789"
// In en-IN should be displayed
"1,23,456.789"
To do that we are gonna use the API Intl.NumberFormat
which works on all browsers.
Implementations
If you look at the documentation of Intl.NumberFormat
, you can see that there is a tone of options available in second parameter, so in our implementation (like with date formatting) we will pass an options object.
Our I18nProvider
becomes then:
function I18nProvider({
children,
locale,
defaultLocale,
messages,
}) {
const getMessage = useCallback(
({ messageKey, defaultMessage }) => {
return (
messages[locale]?.[messageKey] ??
defaultMessage ??
messages[defaultLocale][messageKey]
);
},
[locale, messages, defaultLocale]
);
const getFormattedDate = useCallback(
(date, options = {}) =>
Intl.DateTimeFormat(locale, options).format(date),
[locale]
);
const getFormattedNumber = useCallback(
(number, options = {}) =>
Intl.NumberFormat(locale, options).format(number),
[locale]
);
const value = useMemo(
() => ({
locale,
getMessage,
getFormattedDate,
getFormattedNumber,
}),
[
locale,
getMessage,
getFormattedDate,
getFormattedNumber,
]
);
return (
<I18nContext.Provider value={value}>
{children}
</I18nContext.Provider>
);
}
Note: When working with currency, it can be boring to always pass the right option so we could make a utility function name
getFormattedCurrency
which only take the currency name in second parameter:
const getFormattedCurrency = useCallback(
(number, currency) =>
Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(number),
[locale]
);
Conclusion
We have seen together how to manage simply manage internationalization in React by using React context. It consists to just pass the locale
, message translations
to the provider and then put utility methods in the context to get a message translated and formatted date, number or currency.
We also used the wonderful API Intl
for formatted date and number which relays on the CLDR.
You can play in live with internationalization here.
Want to see more ? Follow me on Twitter or go to my Website. 🐼
Top comments (0)