Today, we will take a look at how we at Harness solved string externalization. There are a lot of packages out there which already solve this, like react-intl and react-i18next. But none of these libraries fit our requirements (at least at the time when we were setting up our project).

The requirements were as follows:

  1. The strings should be easy to update for our documentation team.
  2. It should support templating.
  3. It should be easy to use for developers.
  4. It should provide validation for presence of string.
  5. It should provide autocomplete.

Both the libraries addressed points 1 and 2, but did not meet the requirements completely for the rest of the points.

What we did not like about react-intl:

  1. It’s huge in size (50kB+).
  2. It requires a custom plugin/transformers.

However, we really liked the API of react-intl and wanted to use a similar API for our own solution.

What we did not like about react-i18next:

  1. The API is not very developer-friendly.

After a bit of discussion with the team, we decided to build an in-house solution. We will take you through the journey.

For requirement #1, we decided to go with YAML for storing our strings, because everyone at Harness is familiar with it (you can use JSON or any other format as well).

For requirement #2, we decided to use mustache.js library for templating because it is small in size and well tested (specifically in terms of security).

For requirements #4 and #5, this can be achieved using TypeScript.

Implementation

Since we need our strings to be accessible anywhere, within the app, we made use of React’s Context API. All the code is available here.

We will be using the following application as our starting point:

This is a very simple application with just 2 pages, namely HomePage.tsx and AboutPage.tsx.

First, we’ll create the context for storing the strings data:

// StringsContext.tsx
import { createContext } from "react";

// initialize the context
const StringsContext = createContext({});

// props for StringsContextProvider
export interface StringsContextProviderProps {
  data: Record<string, any>;
}

// a wrapper for StringsContext.Provider for better API
export function StringsContextProvider(
  props: React.PropsWithChildren<StringsContextProviderProps>
) {
  return (
    <StringsContext.Provider value={props.data}>
      {props.children}
    </StringsContext.Provider>
  );
}

Now, let’s add a file to store our strings. We’ll be using a YAML file, but you can use any format you want. We’ll also need to create a strings.yaml.d.ts file for enabling type-safety. For now, we’ll make it generic and come back to it at a later stage.

Add the following content to strings.yaml.d.ts file.

// strings.yaml.d.ts
declare const strings: Record<string, string>;
export default strings;

Now, we can add some content to the strings.yaml file:

homePageTitle: Home
aboutPageTitle: About
homePageContent:
  para1: |
    Lorem ipsum dolor sit amet consectetur adipisicing elit. Aliquam, eos
    animi. Corporis harum quaerat dicta possimus illum aut unde laborum
    excepturi suscipit quibusdam perspiciatis dolorum alias, exercitationem
    similique illo? Distinctio.
  para2: |
    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Reprehenderit
    soluta aliquam sit aliquid qui possimus? Doloremque voluptatibus
    perferendis, quas laboriosam alias eum minus pariatur, temporibus ab
    sequi facilis eaque numquam.
  para3: |
    Lorem ipsum dolor sit amet consectetur adipisicing elit. Explicabo
    optio, fuga cum velit corporis ipsam labore saepe assumenda inventore
    nobis. Laboriosam, cumque blanditiis minima accusantium inventore
    mollitia dolor optio exercitationem?
aboutPageContent:
  para1: |
    Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus
    enim accusantium cum aspernatur repellat saepe similique rerum,
    voluptatum quas voluptas omnis sint reprehenderit modi, ducimus
    provident, reiciendis porro odit impedit.
  para2: |
    Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis animi
    nisi quasi saepe nulla, aperiam blanditiis nihil quae praesentium neque
    temporibus, culpa magni excepturi ipsum nemo ratione! Recusandae,
    distinctio adipisci.
  para3: |
    Lorem ipsum, dolor sit amet consectetur adipisicing elit. Eligendi
    labore architecto recusandae nesciunt rem quo dolore quas nisi modi,
    quam quaerat voluptates nostrum, similique atque earum cumque eaque
    laudantium alias?

Let’s use the StringsContextProvider. For this, we will need to configure our build to be able to load a .yaml file. The method will vary based on the tooling that you’re using. For the purposes of this project, I’m using vitejs and will use @rollup/plugin-yaml.

We’ll need to update vite.config.js to use this plugin:

// vite.config.js
+import yaml from "@rollup/plugin-yaml";
+
/**
  * @type {import('vite').UserConfig}
  */
const config = {
  root: "./src",
+  plugins: [yaml()],
};

Update index.tsx to use StringsContextProvider:

// index.tsx
import HomePage from "./HomePage";
import AboutPage from "./AboutPage";
import Nav from "./Nav";
+import strings from "./strings.yaml";
+import { StringsContextProvider } from "./StringsContext";

function App() {
  return (
+    <StringsContextProvider data={strings}>
      <div style={{ maxWidth: "500px" }}>
      <Nav />
      <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/about" element={<AboutPage />} />
        </Routes>
      </div>
+    </StringsContextProvider>
  );
}

Next, we’ll make the consumer part of the framework.

// StringsContext.tsx
export function useStringsContext(): Record<string, any> {
  return React.useContext(StringsContext);
}

export interface UseLocaleStringsReturn {
  getString(key: string): string;
}

export function useLocaleStrings() {
  const strings = useStringsContext();

  return {
    getString(key: string): string {
      if (key in strings) {
        return strings[key];
      }

      throw new Error(`Strings data does not have a definition for: "${key}"`);
    },
  };
}

Next, we’ll consume the useLocaleStrings hook:

// usage in Nav.tsx
import React from "react";
import { Link } from "react-router-dom";

+ import { useLocaleStrings } from './StringsContext'

export default function Nav(): React.ReactElement {
+  const { getString } = useLocaleStrings()

  return (
    <nav>
      <ul>
        <li>
-          <Link to="/">Home</Link>
+          <Link to="/">{getString("homePageTitle")}</Link>
        </li>
        <li>
-          <Link to="/about">About</Link>
+          <Link to="/about">{getString("aboutPageTitle")}</Link>
        </li>
      </ul>
    </nav>
  );
}

At this point, we’ll be able to see that we are now using the strings in <Nav/> component from the strings.yaml file:

We can improve the API further by making use of the get and has utility functions from lodash (note: we can also use some equivalent library/utility instead).

// StringsContext.tsx
+ import has from "lodash.has";
+ import get from "lodash.get";

export function useLocaleStrings() {
  const strings = useStringsContext();

  return {
    getString(key: string): string {
-      if (key in strings) {
+      if (has(strings, key)) {
-        return strings[keys];
+        return get(strings, key);
      }

      throw new Error(`Strings data does not have a definition for: "${key}"`);
    },
  };
}

This enhancement will allow us to access nested objects just using a string.

// HomePage.tsx
import React from "react";
+import { useLocaleStrings } from "./StringsContext";

export default function HomePage(): React.ReactElement {
+  const { getString } = useLocaleStrings();

  return (
    <div>
      <h1>Home</h1>
-     <p>
-       Lorem ipsum dolor sit amet consectetur adipisicing elit. Aliquam, eos
-       animi. Corporis harum quaerat dicta possimus illum aut unde laborum
-       excepturi suscipit quibusdam perspiciatis dolorum alias, exercitationem
-       similique illo? Distinctio.
-     </p>
+      <p>{getString("homePageContent.para1")}</p>
      <p>
        Lorem ipsum dolor sit amet, consectetur adipisicing elit. Reprehenderit
        soluta aliquam sit aliquid qui possimus? Doloremque voluptatibus
        perferendis, quas laboriosam alias eum minus pariatur, temporibus ab
        sequi facilis eaque numquam.
      </p>

      <p>
        Lorem ipsum dolor sit amet consectetur adipisicing elit. Explicabo
        optio, fuga cum velit corporis ipsam labore saepe assumenda inventore
        nobis. Laboriosam, cumque blanditiis minima accusantium inventore
        mollitia dolor optio exercitationem?
      </p>
    </div>
  );
}

We can also make a react component for using the strings:

// StringsContext.tsx
export interface LocaleStringProps extends React.HTMLAttributes<any> {
  strKey: string;
  as?: keyof JSX.IntrinsicElements;
}

export function LocaleString(props: LocaleStringProps): React.ReactElement {
  const { strKey, as, ...rest } = props;
  const { getString } = useLocaleStrings();
  const Component = as || "span";

  return <Component {...rest}>{getString(strKey)}</Component>;
}

Let’s update HomePage.tsx:

// HomePage.tsx
import React from "react";
-import { useLocaleStrings } from "./StringsContext";
+import { useLocaleStrings, LocaleString } from "./StringsContext";

export default function HomePage(): React.ReactElement {
  const { getString } = useLocaleStrings();

  return (
    <div>
      <h1>Home</h1>
      <p>{getString("homePageContent.para1")}</p>
-     <p>
-       Lorem ipsum dolor sit amet, consectetur adipisicing elit. Reprehenderit
-       soluta aliquam sit aliquid qui possimus? Doloremque voluptatibus
-       perferendis, quas laboriosam alias eum minus pariatur, temporibus ab
-       sequi facilis eaque numquam.
-     </p>
+     <LocaleString strKey="homePageContent.para2" as="p" />
      <p>
        Lorem ipsum dolor sit amet consectetur adipisicing elit. Explicabo
        optio, fuga cum velit corporis ipsam labore saepe assumenda inventore
        nobis. Laboriosam, cumque blanditiis minima accusantium inventore
        mollitia dolor optio exercitationem?
      </p>
    </div>
  );
}

Now, we’ll add support for templatization, using mustache.js.

// StringsContext.tsx
+ import mustache from 'mustache';

export interface UseLocaleStringsReturn {
-  getString(key: string): string
+  getString(key: string, variables?: any): string
}

export function useLocaleStrings() {
  const strings = useStringsContext();

  return {
-    getString(key: string): string {
+    getString(key: string, variables: any = {}): string {
      if (has(strings, key)) {
+        const str = get(strings, key);

-        return get(strings, key);
+        return mustache.render(str, variables);
      }

      throw new Error(`Strings data does not have a definition for: "${key}"`);
    },
  };
}

export interface LocaleStringProps extends HTMLAttributes<any> {
  strKey: string;
  as?: keyof JSX.IntrinsicElements;
+ variables?: any
}

export function LocaleString(props: LocaleStringProps): React.ReactElement {
-  const { strKey, as, ...rest } = props;
+  const { strKey, as, variables, ...rest } = props;
  const { getString } = useLocaleStrings();
  const Component = as || "span";

-  return <Component {...rest}>{getString(strKey)}</Component>;
+  return <Component {...rest}>{getString(strKey, variables)}</Component>;
}
# strings.yaml
homePageContent:
  para1: |
+   Hello {{name}}
    Lorem ipsum dolor sit amet consectetur adipisicing elit. Aliquam, eos
    animi. Corporis harum quaerat dicta possimus illum aut unde laborum
    excepturi suscipit quibusdam perspiciatis dolorum alias, exercitationem
    similique illo? Distinctio.
  para2: |
+    Hello {{name}}
    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Reprehenderit
    soluta aliquam sit aliquid qui possimus? Doloremque voluptatibus
    perferendis, quas laboriosam alias eum minus pariatur, temporibus ab
    sequi facilis eaque numquam.

If you look at the application, we’ll see just the word Hello without the name.

Let’s update our components to fill in the data:

import React from "react";
import { useLocaleStrings, LocaleString } from "./StringsContext";

export default function HomePage(): React.ReactElement {
  const { getString } = useLocaleStrings();

  return (
    <div>
      <h1>Home</h1>
-     <p>{getString("homePageContent.para1")}</p>
+     <p>{getString("homePageContent.para1", { name: "John Doe 1" })}</p>
-     <LocaleString strKey="homePageContent.para2" as="p" />
+     <LocaleString
+       strKey="homePageContent.para2"
+       as="p"
+       variables={{ name: "World!" }}
+     />
      <p>
        Lorem ipsum dolor sit amet consectetur adipisicing elit. Explicabo
        optio, fuga cum velit corporis ipsam labore saepe assumenda inventore
        nobis. Laboriosam, cumque blanditiis minima accusantium inventore
        mollitia dolor optio exercitationem?
      </p>
    </div>
  );
}

Now, we’ll see the strings with data populated correctly:

This is how we can organize our strings in separate files and use them within our application.

All the code used in this blog is available at GitHub. This blog is getting a little long, so we’ll share Externalizing Strings in React Part 2 soon. In part 2, we’ll take a look at how can we leverage TypeScript in providing static analysis, validation, and autocomplete. Join us then!