Building a minimal i18n library for react app - Part 2
Tags: i18n, react, internalization, translation
Published: 2023-08-19 06:02:39

### Translations fetch util

- Create a utility method to dynamically import the translations JSON for the given locale.

```bash
touch src/translations/translations-fetch.util.ts
```

```js
/* 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.

```bash
touch src/translations/translations.tsx
```

```js
/* 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.

```bash
touch src/translations/translate-helper.util.ts
```

```js
/* 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
}
```

#### 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.

```bash
  touch src/translations/use-translation.tsx
```

```js
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.

```bash
touch src/translations/render-translation-with-html.tsx
```

```js
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

```bash
touch src/translations/index.ts
```

```js
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 the `main.tsx` like below,

```js
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.

```js
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.

```js
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

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mhkx42mkq3duzlxvgyvz.gif)

## Sample repo

The code for this post is hosted in Github [here](https://github.com/vinomanick/i18n-app).

Please take a look at the Github repo and let me know your feedback, and queries in the comments section.