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:
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:
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:
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.
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!