Translations fetch util
- Create a utility method to dynamically import the translations JSON for the given locale.
touch src/translations/translations-fetch.util.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TranslationsMap } from './translations.interfaces'
const isObject = (data: any) => data && typeof data === 'object'
const mergeDeep = (
target: Record<string, any>,
...sources: Record<string, any>[]
): Record<string, any> => {
if (!sources.length) return target
const source = sources.shift()
if (isObject(target) && isObject(source)) {
for (const key in source) {
if (isObject(source[key])) {
if (!target[key]) {
Object.assign(target, { [key]: {} })
}
mergeDeep(target[key], source[key])
} else {
Object.assign(target, {
[key]: source[key],
})
}
}
}
return mergeDeep(target, ...sources)
}
export const getTranslations = ({
translationsMap = { en: () => Promise.resolve({}) },
locale,
defaultLocale,
}: {
translationsMap?: TranslationsMap,
locale: string,
defaultLocale: string,
}) => {
if (locale === defaultLocale || !translationsMap[locale]) {
return translationsMap[defaultLocale]()
}
return Promise.all([
translationsMap[defaultLocale](),
translationsMap[locale](),
]).then(([defaultTranslations, userLocaleTranslations]) =>
mergeDeep({}, defaultTranslations, userLocaleTranslations)
)
}
Responsibilities:
- The "getTranslations" method dynamically imports and returns the translation for the default locale if the user locale and default locale are the same or the user locale does not have an entry in the translations map config.
- The "getTranslations" method dynamically imports the translations for both the user locale and default locale and returns the merged translations of both locales if they are different.
Setting up the Translations wrapper
- Create the translations component which is going to act as a wrapper for our app and pass in the translation context to all the components.
touch src/translations/translations.tsx
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ReactNode, useEffect, useState } from 'react'
import { TranslationsContext } from './translations-context'
import { TranslationsMap } from './translations.interfaces'
import { getTranslations } from './translations-fetch.util'
interface TranslationsProps {
children: ReactNode;
defaultLocale: string;
initLocale?: string;
translationsMap?: TranslationsMap;
}
export const Translations = ({
children,
defaultLocale,
initLocale = '',
translationsMap,
}: TranslationsProps) => {
const [isLoading, setIsLoading] = useState(true)
const [locale, setLocale] = useState(initLocale)
const [translations, setTranslations] = useState({})
useEffect(() => {
if (locale) {
setIsLoading(true)
getTranslations({ translationsMap, locale, defaultLocale }).then((response) => {
setTranslations(response)
setIsLoading(false)
})
}
}, [defaultLocale, locale, translationsMap])
return (
<TranslationsContext.Provider
value={{ locale, setLocale, isLoading, translations, defaultLocale }}
>
{children}
</TranslationsContext.Provider>
)
}
Input props:
defaultLocale - The primary locale which is expected to have a valid JSON with entries for all the translation keys used in the project.
initLocale - This is an optional prop, if provided the translations component will load the given locale translations during the app load time itself. The reason for keeping this as an optional prop is sometimes the user locale information might be obtained from the backend server via API, that time use the setLocale
helper method of the useTranslation
hook once the information is fetched.
translationsMap - This should be an object that contains the supported locales as a key and its corresponding dynamic import loader as the value.
Responsibilities:
- Have a local state to keep track of the translations loading state.
- Have a local state to keep the user-selected locale
- Whenever the user-selected locale is changed, update the loader state and load the translations using the "getTranslations" utility method.
- Wraps the given children inside the translation context provider.
Translate helper util
- Create a utility method to traverse the given translation key and return the corresponding value.
touch src/translations/translate-helper.util.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TranslateOptions, TranslateParams } from './translations.interfaces'
const ESCAPE_KEYS: Record<string, string> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/',
}
const hasOwnProperty = (data: any, property: string) =>
data && typeof data === 'object' && Object.prototype.hasOwnProperty.call(data, property)
const getPlural = (count: number, locale: string) => {
if (typeof Intl == 'object' && typeof Intl.PluralRules == 'function') {
return new Intl.PluralRules(locale).select(count)
}
return count === 0 ? 'zero' : count === 1 ? 'one' : 'other'
}
export const translate = (
translations: Record<string, any> = {},
locale: string,
key: string,
params: TranslateParams = {},
options: TranslateOptions = { escapeValue: true }
): string => {
let result: any = translations
let currentKey = key
const { count } = params
if (hasOwnProperty(params, 'count') && typeof count === 'number') {
const plural = getPlural(count, locale)
if (count === 0) {
currentKey += '.zero'
} else if (plural === 'other') {
currentKey += '.many'
} else {
currentKey += `.${plural}`
}
}
currentKey.split('.').forEach((k: string) => {
if (result[k]) {
result = result[k]
}
})
if (typeof result !== 'string') {
console.warn(`Missing translation for ${key}`)
return ''
}
const getParamValue = (paramKey: string) => {
console.log(paramKey, params)
const value = params[paramKey]
return options.escapeValue && typeof options === 'string'
? value.replace(/[&<>"'\\/]/g, (key: string) => ESCAPE_KEYS[key])
: value
}
return Object.keys(params).length
? result.replace(/\${(.+?)\}/gm, (_, varName) => getParamValue(varName))
: result
}
Responsibilities:
- Should throw a 'Missing key' console warning if the given key is not present in the translations object.
- Should replace the params specified in the translation file with the value provided by the user.
- Should sanitize the param value for any HTML syntax if the "escapeValue" option is true and ignore otherwise.
- Should pluralize the translation if the params have a special param named
count
.
Translation hook setup
Create the
useTranslation
hook which will return the following props that can be used in the child components.setLocale
method will update the locale when called.isLoading
returns true when the translations are getting loaded.t
helper method will return the translated value for the given key in the current locale.
touch src/translations/use-translation.tsx
import { useContext } from 'react'
import { TranslationsContext } from './translations-context'
import { TranslateOptions, TranslateParams } from './translations.interfaces'
import { translate } from './translate-helper.util'
type THelper = (key: string, params?: TranslateParams, options?: TranslateOptions) => string
export interface UseTranslation {
isLoading: boolean;
locale: string;
setLocale: (language: string) => void;
t: THelper;
}
export const useTranslation = (): UseTranslation => {
const { setLocale, isLoading, translations, locale } = useContext(TranslationsContext)
return {
isLoading,
locale,
setLocale,
t: (key, params = {}, options) => translate(translations, locale, key, params, options),
}
}
Render translations with HTML support
- Create a custom component that can render the HTML tags present in the translation parameters.
touch src/translations/render-translation-with-html.tsx
import { TranslateParams } from './translations.interfaces'
import { useTranslation } from './use-translation'
export const RenderTranslationWithHtml = ({
tKey,
params,
className,
}: {
tKey: string,
params?: TranslateParams,
className?: string,
}) => {
const { t } = useTranslation()
const htmlString = t(tKey, params, { escapeValue: false })
return (
<span
className={className}
dangerouslySetInnerHTML={{
__html: htmlString,
}}
/>
)
}
Export the translations component from the root
touch src/translations/index.ts
export * from './translations'
export * from './use-translation'
export * from './render-translation-with-html'
That's it. We have created our translation library with some basic features. Let's integrate it into our app and verify it.
Integrate the app with translations
- Let's wrap our react app inside the
Translations
component by updating themain.tsx
like below,
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { Translations } from './translations';
import { TRANSLATIONS_MAPPING } from './locales/index.ts';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Translations
defaultLocale="en"
initLocale="en"
translationsMap={TRANSLATIONS_MAPPING}
>
<App />
</Translations>
</React.StrictMode>
);
- Create an app header component with the language change dropdown.
import { ChangeEvent } from 'react'
import { useTranslation } from './translations'
export const AppHeader = () => {
const { setLocale, locale } = useTranslation()
return (
<select
value={locale}
onChange={(event: ChangeEvent<HTMLSelectElement>) => setLocale(event.target.value)}
>
<option value="en">English</option>
<option value="fr">French</option>
</select>
)
}
- Update the
App.tsx
file to use the translated values instead of strings.
import { AppHeader } from './AppHeader'
import { useTranslation } from './translations'
const App = () => {
const { t, isLoading } = useTranslation()
return isLoading ? (
<>Loading...</>
) : (
<>
<AppHeader />
<h1>{t('app.heading')}</h1>
</>
)
}
export default App
Demo
Sample repo
The code for this post is hosted in Github here.
Please take a look at the Github repo and let me know your feedback, and queries in the comments section.
Top comments (0)