DEV Community

Cover image for Simplifying Multilingual React Apps with Custom Hooks and Utility Types
Serif COLAKEL
Serif COLAKEL

Posted on

Simplifying Multilingual React Apps with Custom Hooks and Utility Types

In modern web development, creating multilingual applications is a common requirement. React, being one of the most popular JavaScript libraries for building user interfaces, offers various approaches to handle internationalization (i18n). However, managing translations and implementing them effectively can sometimes be challenging.

In this article, we'll explore a practical approach to manage translations in React applications using custom hooks and utility types. We'll break down the process step by step, explaining how to create a reusable useTranslation hook and define utility types for better type safety.

Understanding the Problem

Before diving into the solution, let's understand the problem we're trying to solve. In a multilingual React app, we need a way to:

  • Manage translations efficiently.
  • Provide a mechanism to switch between languages.
  • Access translated strings in components.
  • Ensure type safety when working with translations.

Solution Overview

To address these requirements, we'll follow these steps:

  • Define Translations: Store translations in JSON files for each language.
  • Create Context: Use React context to manage language settings and translations.
  • Custom Hook for Translation: Implement a custom hook, useTranslation, to access translations conveniently.
  • Utility Types: Define utility types for better type safety and code readability.
  1. Define Translations

Translations are typically stored in JSON files, with each file representing a language. For example, en.json for English and tr.json for Turkish. Here's an example of how translations can be structured:

  • en.json
{
  "Index": {
    "title": "Index",
    "text": "Hello world!",
    "message": "Welcome to Translations!",
    "languageAutocomplete": {
      "label": "Pick a language",
      "placeholder": "Select Language",
      "helperText": "Select a language, You can type to search"
    },
    "deep": {
      "label": "Deep Label",
      "placeholder": "Deep PlaceHolder",
      "helperText": "Deep Helper Text",
      "deep": {
        "label": "Deep Label",
        "placeholder": "Deep PlaceHolder",
        "helperText": "Deep Helper Text",
        "deep": {
          "label": "Deep Label",
          "placeholder": "Deep PlaceHolder",
          "helperText": "Deep Helper Text",
          "deep": {
            "label": "Deep Label",
            "helperText": "Deep Helper Text",
            "placeholder": "Deep PlaceHolder",
            "deep": {
              "label": "Deep Label",
              "helperText": "Deep Helper Text",
              "placeholder": "Deep PlaceHolder"
            }
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  • tr.json
{
  "Index": {
    "title": "Başlık",
    "text": "Merhaba Dünya!",
    "message": "Translations'a hoşgeldiniz!",
    "languageAutocomplete": {
      "label": "Dil Seçimi",
      "placeholder": "Lütfen bir dil seçin",
      "helperText": "Dil seçimi yapınız, arama yapabilirsiniz"
    },
    "deep": {
      "label": "Derinlik Label",
      "placeholder": "Derinlik PlaceHolder",
      "helperText": "Derinlik Yardımcı Metin",
      "deep": {
        "label": "Derinlik Label",
        "placeholder": "Derinlik PlaceHolder",
        "helperText": "Derinlik Yardımcı Metin",
        "deep": {
          "label": "Derinlik Label",
          "placeholder": "Derinlik PlaceHolder",
          "helperText": "Derinlik Yardımcı Metin",
          "deep": {
            "label": "Derinlik Label",
            "placeholder": "Derinlik PlaceHolder",
            "helperText": "Derinlik Yardımcı Metin",
            "deep": {
              "label": "Derinlik Label",
              "placeholder": "Derinlik PlaceHolder",
              "helperText": "Derinlik Yardımcı Metin"
            }
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Create Context

We'll use React context to manage language settings and translations. This context will provide the necessary data to components throughout the app.

  • First we'll create a context file, translations.constants.ts, to define the context and its initial state:
/* eslint-disable import/no-cycle */
import { createContext } from 'react';
import { TranslationContextValue } from './translations.types';

export const languages = {
  en: 'en',
  tr: 'tr',
} as const;

// Create the context
export const TranslationContext = createContext<
  TranslationContextValue | undefined
>(undefined);
Enter fullscreen mode Exit fullscreen mode
  • The useTranslations hook will be defined in translations.hooks.ts:
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback, useContext, useEffect } from 'react';
import { TranslationContext, languages } from './translations.constants';
import { TranslationKeys, TFunction, Language } from './translations.types';
import en from '../translations/en.json';
import tr from '../translations/tr.json';

// Create a custom hook to access the context value
export function useTranslation<K extends TranslationKeys>(
  rootKey?: K
): {
  t: TFunction<K>;
  setLanguage: React.Dispatch<React.SetStateAction<Language>>;
  language: Language;
} {
  const context = useContext(TranslationContext);

  if (!context) {
    throw new Error('useTranslation must be used within a TranslationProvider');
  }

  const { translations, language, setLanguage, setTranslations } = context;

  useEffect(() => {
    const prepareTranslation = async () => {
      if (language === languages.tr) {
        setTranslations(tr);
      }

      if (language === languages.en) {
        setTranslations(en);
      }
    };

    prepareTranslation();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [language]);

  const t = useCallback<TFunction<K>>(
    (key, params?: Record<string, string>): string | undefined => {
      const mergeKey = rootKey ? `${rootKey}.${key}` : key;

      if (!translations) {
        return String(mergeKey);
      }

      let translation = mergeKey.split('.').reduce(
        (acc, current) => {
          return acc?.[current] || String(mergeKey);
        },
        translations as Record<string, any>
      );

      if (params) {
        Object.keys(params).forEach((param) => {
          translation = translation.replace(
            new RegExp(`{{${param}}}`, 'g'),
            params[param]
          );
        });
      }

      return String(translation);
    },
    [rootKey, translations]
  );

  return { t, setLanguage, language };
}
Enter fullscreen mode Exit fullscreen mode
  • Finally, we'll create a TranslationProvider component in translations.provider.tsx:
import React, { useMemo, useState } from 'react';

import { TranslationContext, languages } from './translations.constants';
import {
  Language,
  Translation,
  TranslationContextValue,
} from './translations.types';

// Create the provider component
type TranslationProviderProps = {
  children: React.ReactNode;
};

export function TranslationProvider({
  children,
}: TranslationProviderProps): JSX.Element {
  const [language, setLanguage] = useState<Language>(languages.en);

  const [translations, setTranslations] = useState<Translation | undefined>();

  const value: TranslationContextValue = useMemo(
    () => ({
      language,
      setLanguage,
      setTranslations,
      translations,
    }),
    [language, translations]
  );

  return (
    <TranslationContext.Provider value={value}>
      {children}
    </TranslationContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode
  1. Utility Types

To ensure type safety and code readability, we'll define utility types that help us work with translations more effectively. These types will provide better autocomplete support and catch errors at compile time.

  • Next, we'll define type in translations.types.ts:
/* eslint-disable @typescript-eslint/no-explicit-any */
import en from '../translations/en.json';
import tr from '../translations/tr.json';
import { languages } from './translations.constants';

export type Translation = typeof en | typeof tr;

export type TranslationKeys = keyof Translation;

export type RemoveTypes<T, U> = {
  [P in keyof T]: T[P] extends U ? never : P;
}[keyof T];

export type FlattenKeys<
  T extends Record<string, any>,
  Prefix extends string = '',
> = {
  [K in keyof T]: T[K] extends Record<string, any>
    ? FlattenKeys<T[K], `${Prefix}${string & K}.`>
    : `${Prefix}${string & K}`;
}[keyof T];

export type TranslationChildKeys = RemoveTypes<
  FlattenKeys<Translation>,
  TranslationKeys
>;

export type TFunction<K extends keyof Translation> = (
  key: K extends TranslationKeys
    ? FlattenKeys<Translation[K]>
    : TranslationChildKeys,
  params?: Record<string, string>
) => string | undefined;

export type Language = (typeof languages)[keyof typeof languages];

// Define the type for your context value
export type TranslationContextValue = {
  translations: Translation | undefined;
  setTranslations: React.Dispatch<
    React.SetStateAction<Translation | undefined>
  >;
  language: Language;
  setLanguage: React.Dispatch<React.SetStateAction<Language>>;
};
Enter fullscreen mode Exit fullscreen mode

Each utility type serves a specific purpose:

  • Translation: Represents the structure of translation files.

  • TranslationKeys: Represents the keys of the translation object.

  • RemoveTypes: Removes specific types from an object.

  • FlattenKeys: Flattens the nested keys of the translation object.

  • TranslationChildKeys: Represents the keys of nested objects in the translation file.

  • TFunction: Represents the translation function signature.

  • Language: Represents the supported languages.

  • TranslationContextValue: Represents the shape of the context value.

  1. Usage in Components

Now that we have defined the translations, context, custom hook, and utility types, we can use them in our components. Here's an example of how to use the useTranslation hook in a component:

// Translations.tsx
import React, { useState } from 'react';
import { useTranslation } from './contexts/translations.hooks';
import { languages } from './contexts/translations.constants';

const languageOptions = [
  {
    code: languages.en,
    name: 'English',
  },
  {
    code: languages.tr,
    name: 'Turkish',
  },
];

function LanguageAutoComplete() {
  const { setLanguage, t, language } = useTranslation('Index');

  const [inputValue, setInputValue] = useState(String(language));

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value);

    if (e.target.value === languageOptions[0].name) {
      setLanguage(languageOptions[0].code);
    } else if (e.target.value === languageOptions[1].name) {
      setLanguage(languageOptions[1].code);
    }
  };

  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'column',
        gap: '10px',
        padding: 16,
        textAlign: 'left',
      }}
    >
      <label htmlFor="lang">{t('languageAutocomplete.label')}</label>
      <input
        id="lang"
        list="items"
        onChange={handleInputChange}
        placeholder={t('languageAutocomplete.placeholder')}
        style={{ padding: 12 }}
        value={inputValue}
      />
      <label htmlFor="lang">{t('languageAutocomplete.helperText')}</label>
      <datalist id="items">
        {languageOptions.map((languageOption) => (
          <option key={languageOption.code} value={languageOption.name}>
            {languageOption.code}
          </option>
        ))}
      </datalist>
    </div>
  );
}

function Form() {
  const { t } = useTranslation('Index');

  return (
    <div>
      <h1>{t('title')}</h1>
      <h1>{t('deep.deep.deep.deep.deep.label')}</h1>
      <h1>{t('deep.deep.deep.deep.deep.placeholder')}</h1>
      <section
        style={{
          display: 'flex',
          flexDirection: 'column',
          textAlign: 'left',
          gap: '10px',
          padding: 16,
        }}
      >
        <label htmlFor="deep">{t('deep.deep.deep.deep.deep.label')}</label>
        <input
          id="deep"
          placeholder={t('deep.deep.deep.deep.deep.placeholder')}
          style={{ padding: 12 }}
          type="text"
        />
        <label htmlFor="deep">{t('deep.deep.deep.deep.deep.helperText')}</label>
      </section>
      <hr />
      <section
        style={{
          display: 'flex',
          flexDirection: 'column',
          textAlign: 'left',
          gap: '10px',
          padding: 16,
        }}
      >
        <label htmlFor="third-deep">{t('deep.deep.deep.label')}</label>
        <input
          id="third-deep"
          placeholder={t('deep.deep.deep.placeholder')}
          style={{ padding: 12 }}
          type="number"
        />
        <label htmlFor="third-deep">{t('deep.deep.deep.helperText')}</label>
      </section>
      <hr />
      <section
        style={{
          display: 'flex',
          flexDirection: 'column',
          textAlign: 'left',
          gap: '10px',
          padding: 16,
        }}
      >
        <label htmlFor="first-deep">{t('deep.label')}</label>
        <input
          id="first-deep"
          placeholder={t('deep.placeholder')}
          style={{ padding: 12 }}
          type="password"
        />
        <label htmlFor="first-deep">{t('deep.helperText')}</label>
      </section>
      <hr />
      <section
        style={{
          display: 'flex',
          flexDirection: 'column',
          textAlign: 'left',
          gap: '10px',
          padding: 16,
        }}
      >
        <label htmlFor="second-deep">{t('deep.deep.label')}</label>
        <input
          id="second-deep"
          placeholder={t('deep.deep.placeholder')}
          style={{ padding: 12 }}
          type="date"
        />
        <label htmlFor="second-deep">{t('deep.deep.helperText')}</label>
      </section>
      <hr />
    </div>
  );
}

export default function Translations() {
  return (
    <div style={{ border: '1px solid gray', width: '100%' }}>
      <Form />
      <LanguageAutoComplete />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
  1. Conclusion

In this article, we've explored a practical approach to manage translations in React applications using custom hooks and utility types. By following the steps outlined above, you can create a reusable useTranslation hook and define utility types for better type safety and code readability.

This approach simplifies the process of handling translations in multilingual React apps, making it easier to manage translations efficiently, switch between languages, access translated strings in components, and ensure type safety when working with translations.

Utility types are an essential part of TypeScript that allow you to manipulate and transform types. They provide a way to create reusable type transformations and simplify complex type operations.

One important utility type is FlattenKeys, which helps flatten nested object keys. This is particularly useful when working with translations, as it allows you to access deeply nested translation keys in a more concise and readable way.

Another useful utility type is RemoveTypes, which allows you to remove specific types from a union or intersection type. This can be handy when you want to exclude certain types from a larger type definition.

Lastly, TFunction is a utility type commonly used in internationalization libraries like i18next. It represents the type of a translation function and ensures type safety when working with translations in your code.

By leveraging these utility types, you can enhance the readability, maintainability, and type safety of your code when dealing with translations in React applications.

I hope you found this article helpful. If you have any questions or feedback, feel free to reach out in the comments below. Happy coding!

Top comments (0)