Fragmented Thought

Typings for React i18n Resources

By

Published:

Lance Gliser

The team behind react-i18next have built a great module, and a wonderful service behind it. If you're choosing to use it with their service, one of the things that can get away from you is the sheer volume of translations and their context. Is that action_button translation the one that says "Buy now" or "Verify email address"... 🤔

With some minor typing work, packaging, and hooks we can know what's in use, where and follow it.

Creating a bundle for a namespace

All of the resources for a language have a namespace, even if it's the default. We can expand on that concept to provide full sets of language translations and the slugs we need in @/utils/translations:

import type { Resource, ResourceLanguage } from "i18next"; export type I18nBundle< Languages extends string, Resource extends ResourceLanguage > = { /** The namespace to be loaded into. */ namespace: string; /** A collection of language codes and resources under them. */ resources: Record<Languages, Resource>; /** A strongly typed set of slugs pointing back to resources we can use. */ strings: Resource; };

This will let us create and use bundles in our code. We'll start by declaring all the languages we need to support in this app as a type in @/constants:

type Languages = "en";

And a utility to create the Resources that i18n expects from an arbitrary object of translations in @/utils/translation:

type ResourceObject = { [key: string]: string | ResourceObject; }; /** * Returns strings for accessing translations dynamically based on a given language resource */ export const getStringsForI18nBundleFromResource = <T extends ResourceObject>( resource: T ): T => { const buildStrings = ( strings: ResourceObject, object: ResourceObject, path: string[] = [] ): ResourceObject => { Object.entries(object).forEach(([key, value]) => { const localPath = [...path, key]; if (typeof value === "object") { strings[key] = {}; buildStrings(strings[key], value, localPath); return; } strings[key] = localPath.join("."); }); return strings; }; return buildStrings({} as T, resource) as T; };

We'll create the bundle and export it from @/services/i18n/common:

const namespace = "Common"; // We can create an exact constant of our strings const en = { error: "Error", image: "Image", index: "Index", home: "Home", loading: "Loading...", profile: "Profile", untitled: "Untitled", actions: { // Cancel cancel: "Cancel", // Create "create_failed_{{reason}}": "Failed to create: {{reason}}", "create_failed_{{identifier}}_{{reason}}": "Failed to create {{identifier}}: {{reason}}", // Delete delete: "Delete", deleted: "Deleted", "deleted_{{identifier}}": "Deleted {{identifier}}", "delete_failed_{{reason}}": "Failed to delete: {{reason}}", "delete_failed_{{identifier}}_{{reason}}": "Failed to delete {{identifier}}: {{reason}}", // Save save: "Save", // Submit submit: "Submit", // View view: "View", "view_{{identifier}}": "View {{identifier}}", }, }; // Then declare it as a a bundle resource type Resource = typeof en; // Further translations could be created using that type: // const fr: Resource = { ... } export const CommonI18nBundle: I18nBundle<Languages, Resource> = { namespace, resources: { en }, // Including fr if desired // The language you provide is unimportant, this function compiles the keys along the path. strings: getStringsForI18nBundleFromResource<Resource>(en), };

Let's make another utility to help bundle the bundles in @/utils/translation:

import type { Resource, ResourceLanguage } from "i18next"; // See type I18nBundle above /** * Takes an existing i18next resource object and adds bundles. * An error will be thrown if a namespace collisions occur. */ export const addI18nBundlesToResources = ( resources: Resource, // eslint-disable-next-line @typescript-eslint/no-explicit-any bundles: I18nBundle<any, ResourceLanguage>[] ): Resource => { const newResources = { ...resources }; const collisions: string[] = []; bundles.forEach((bundle) => { Object.entries(bundle.resources).forEach(([locale, translations]) => { newResources[locale] ||= {}; if (newResources[locale][bundle.namespace]) { collisions.push(bundle.namespace); return; } newResources[locale][bundle.namespace] = translations; }); }); if (collisions.length > 0) { throw new Error( `Il8nBundles include conflicting namespace(s): ${collisions.join(", ")}` ); } return newResources; };

Then we can create inline, or import as required all our bundles to register with i18n:

import i18next from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import { initReactI18next } from "react-i18next"; import { CommonI18nBundle } from "@/services/i18n/common"; import { addI18nBundlesToResources } from "@/utils/translation"; const resources = addI18nBundlesToResources({}, [ CommonI18nBundle, // Our local components and routes may include additional `export * from "./i18n.ts"` // We'll include those automatically as well. // ...Object.values(ComponentsI18n), // ...Object.values(RoutesI18n), ]); // eslint-disable-next-line @typescript-eslint/no-floating-promises i18next .use(initReactI18next) // Tell i18next to use the react-i18next plugin .use(LanguageDetector) // Setup a client-side language detector // init i18next // for all options read: https://www.i18next.com/overview/configuration-options .init({ resources, // This is the language you want to use in case the user language is not supported fallbackLng: "en", // Namespace to use when no namespace is specified defaultNS: "common", // Run in debug when in dev // debug: !import.meta.env.PROD, // Recommended to be enabled when using react-i18next react: { useSuspense: true }, interpolation: { escapeValue: false, // not needed for react as it escapes by default }, });

Using bundles

With bundles typed and resources registered we're ready to start using those strings. Let's start by creating a basic hook that returns the bundle for use:

import { CommonI18nBundle } from "@/services/i18n/common"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; export const useCommonTranslations = () => { const { t } = useTranslation(CommonI18nBundle.namespace); // Since we're creating a new object, it's critical you memoize this. return useMemo( () => ({ t, namespace: CommonI18nBundle.namespace, strings: CommonI18nBundle.strings, }), [t] ); };

This can be expanded on with strongly typed functions for specific translations. This also gives a great minimal look at usage patterns:

export const useIdentifierUnavailableTranslation = ( identifier: string | undefined | null ): string => { const { t, strings } = useCommonTranslations(); identifier ||= t(strings.content); return t(strings.state["{{identifier}}_unavailable"], { identifier }); };

You'll have full typing and autocomplete for strings.... automatically!

Further consideration

This is a simplified example, but it is often sufficient if you publish in only one language. Once you start using multiple, additional work will be required to help lazy load only the desired language. Some adaption will be required, but the basic process should be sufficient.

More immediately critical, you should carefully consider the upfront cost for resources. Unless you implement lazy loading for resources via an API as suggested above, this will ship all registered bundles to the browser in the initial chunk. That might be fine if you're app has only its translations. If you're using this approach in a component library, ensure every component, or at least group of related components, uses a separate bundle the end application can opt into individually!