DEV Community

Vinodh Kumar
Vinodh Kumar

Posted on

Building a minimal i18n library for react app - Part 2

Translations fetch util

  • Create a utility method to dynamically import the translations JSON for the given locale.
touch src/translations/translations-fetch.util.ts
Enter fullscreen mode Exit fullscreen mode
/* 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)
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
/* 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TranslateOptions, TranslateParams } from './translations.interfaces'

const ESCAPE_KEYS: Record<string, string> = {
  '&': '&amp;',
  '<': '&lt;',
  '>': '&gt;',
  '"': '&quot;',
  "'": '&#39;',
  '/': '&#x2F;',
}

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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),
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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,
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Export the translations component from the root

touch src/translations/index.ts
Enter fullscreen mode Exit fullscreen mode
export * from './translations'
export * from './use-translation'
export * from './render-translation-with-html'
Enter fullscreen mode Exit fullscreen mode

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 the main.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>
);
Enter fullscreen mode Exit fullscreen mode
  • 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>
  )
}
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

Demo

Image description

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)