㊗️ Down to earth framework-less localization for JS apps2020-09-28

㊗️ Down to earth framework-less localization for JS apps

Any large application at some point requires localization. So no wonder why there are so many frameworks that solve this particular issue. But is it possible to localize any kind of JS application without any frameworks? Yes it is. And let me show you how.

First of all, let's list the major requirements:

  1. Use no frameworks
  2. Use standard techniques, no hacks
  3. Ideal code completion: ability to navigate from any place of the system to a particular string, autocompletion when you type, code navigation and static analysis must work
  4. Manageability on a scale: resource files has to be duplicate-free, statically parseable, shippable to localization vendors
  5. Ability to change locale on the fly in runtime without page reloads
  6. Locale and UI must be synchronized in all open tabs immediately after locale changes

File format

Now when requirements have been set we need to decide how to store localizations.

Some approaches use english strings as keys for other localizations like so (pardon my French, pun intended):

// fr-FR.json
{ 'Hello world': 'Bonjour le monde' }

This is a no go for a truly large scale project. It does not scale well and it leads to enormous issues during refactoring since. It is also hard to harvest strings from JS files (something like <div>t('Hello World')</div> where t is a translation function and Hello Wolrd will be used as key to find proper equivalent in another language). Also code completion won't work, developer won't be able to understand which strings were used before to reuse them.

So we will use predefined keys instead:

// en-US.json
{ HELLO_WORLD: 'Hello world' }
// fr-FR.json
{ HELLO_WORLD: 'Bonjour le monde' }

With this we can refer to any string by its key.

But what about autocompletion if we store strings in JSON files? Not every IDE has autocompletion that is smart enough. Let's use plain old JS files and exports (although that's optional if your IDE can work with JSON well):

// en-US.js
export default {
  HELLO_WORLD: 'Hello world',
};

Usage in components

Now, how can we use it in components? Easily, just import it:

// App.jsx
import React from 'react';
import lang from './lang/en-US';

export default () => <div>{lang.HELLO_WORLD}</div>;

Of course such approach has a limitation: we can only use it in render methods or in functions that are called in runtime.

import lang from './lang/en-US';
const HW = lang.HELLO_WORLD; //BAD this is a no go because if locale will be changed HW will have old value
const getHW = () => lang.HELLO_WORLD; //GOOD we always use current state

But what to do if we would want to create a class with some default props? We can do the following trick with the getter:

import React from 'react';
import lang from './lang/en-US';

export default class HelloWorld extends React.Component {
  static defaultProps = {
    get text() {
      return lang.HELLO_WORLD;
    },
  };

  render() {
    return <div>{this.props.text}</div>;
  }
}

We just got the auto-completable usage of our strings. In pure JS.

Locale switching

But how to switch locale?

We can create a special micro-loader:

// loader.js
export default async (locale) => {
  switch (locale) {
    case 'fr-FR':
      return (await import(/* webpackChunkName:'lang/fr-FR' */ './lang/fr-FR')).default;
    default:
      return null; // we already import en-US everywhere
  }
};

This file of course can be code-generated, but manual changes are easy and rare.

But how will this loader affect what we had before? We were importing en-US in all of the components, so how to swap to another locale?

JS Modules are singletons, so no matter how many times and where we import a module we will always get the same object. So all we need to do when we load locale is to overwrite en-US strings with something else:

// setLocale.js
import loader from './loader';
import defaultStrings from './lang/en-US';

const defaultStringsClone = Object.assign({}, defaultStrings); // JS Modules cannot be cloned directly because keys are getters

export const setLocale = async (locale) => {
  let newStrings = await loader(locale);

  // loader returns null if locale is default
  if (!newStrings) newStrings = defaultStringsClone;

  Object.assign(defaultStrings, defaultStringsClone, newStrings);

  return defaultStrings;
};

Now all we need is to call the setLocale(whatever) when we need to swap the in-memory locale and before everything, even ReactDOM.render(). Also notice that this simple mechanism gives a free fallback to english if localized string won't be found. This simple example assume that you have flat key-value pairs, if you use complex objects you can take something like Lodash's merge and cloneDeep but we agreed not to use frameworks in this article, so we keep things simple.

Here's a small Gate component that will make sure nothing is rendered before localized strings are ready:

// Gate.jsx
import React, { Component } from 'react';
import { setLocale } from './setLocale';

export default class Gate extends React.Component {
  state = { loading: true };
  mounted = false; // this is a hack... so sad
  async componentDidMount() {
    this.mounted = true;
    await setLocale(localStorage.locale); // or whatever you use to store locale, cookie, query string, etc.
    if (this.mounted) this.setState({ loading: false });
  }
  componentWillUnmount() {
    this.mounted = false;
  }
  render() {
    const { loading } = this.state;
    const { children } = this.props;
    return children(loading);
  }
}

// index.js
import React from 'react';
import { render } from 'react-dom';
import Gate from './Gate.jsx';
import App from './App.jsx';
render(<Gate>{(loading) => (loading ? 'spinner-image-placeholder' : <App />)}</Gate>, document.getElementById('root'));

This whole schema works fine if you don't plan to swap locale on the fly without reloading the page. It's important to remember that in real life during the lifecycle of the app most likely some data has already been downloaded from the server. Best practices suggest that APIs should be locale-free (e.g. no translated strings, currencies, date formatting, etc.), but also there are lots of legit cases when backend logic is using user locale and/or produce pre-formatted data. In such case app has to redownload all API requests, which could be a quite challenging task.

It is much easier to just reload the whole app to make sure nothing is broken. How often users change their language preference? It is quite a rare occasion, so there has to be a good reason to make things more complicated.

And surely we will explore dynamic case too.

Dynamic locale switching, tab synchronization

Assume we have a good API, which serve raw data and we want to make the best user experience and allow to swap locale at any time. Moreover, it would be quite embarassing if locale has changed in one tab of the browser and some other tab still would have old locale until the page reload.

In order to achieve great UX we need to make sure React (or other framework) re-renders if in-memory string are changed. This means some sort of a pub-sub (publish-subscribe) pattern has to be utilized. Which has to work in multiple tabs. LocalStorage can be used as synchronization object, as it can store values between reloads and can send messages across tabs.

First of all, let's alter the setLocale code to be able to send some event when locale is changed.

// setLocale.js
import loader from './loader';
import defaultStrings from './lang/en-US';

const STORAGE_KEY = 'locale';
const EVENT = 'storage';

const defaultStringsClone = Object.assign({}, defaultStrings); // JS Modules cannot be cloned directly because keys are getters

export const setLocale = async (locale) => {
  let newStrings = await loader(locale);

  // loader returns null if locale is default
  if (!newStrings) newStrings = defaultStringsClone;

  Object.assign(defaultStrings, defaultStringsClone, newStrings);

  // THIS IS PUBLISHER ⬇️
  const oldLocale = getLocale();
  localStorage.setItem(STORAGE_KEY, locale);
  window.dispatchEvent(
    new StorageEvent(EVENT, {
      // we need to dispatch event manually inside current tab
      key: STORAGE_KEY,
      newValue: locale,
      oldValue: oldLocale,
    }),
  );
  // THIS IS PUBLISHER ⬆️

  return defaultStrings;
};

export const getLocale = () => localStorage.getItem(STORAGE_KEY);

Please note that we need to dispatch event manually inside current tab since StorageEvents will appear only in other open tabs, not current one.

Next we need to create a subscription mechanism:

// setLocale.js
// ... everything from above

/**
 * @param {StorageEvent} e
 * @returns {boolean}
 */
const verifyEvent = (e) => e.key === STORAGE_KEY && e.newValue !== e.oldValue;

export const makeListener = (fn) => {
  const listener = (e) => verifyEvent(e) && setLocale(e.newValue) && fn(e.newValue);
  window.addEventListener(EVENT, listener, false);
  return () => window.removeEventListener(EVENT, listener);
};

Obviously, there's still plenty of room for improvements. For example, we chose not to use any frameworks, but we can optimize the performance of straightforward subscriptions if we use state management tools like MobX or Redux to store locale and do synchronization.

Now let's create a link between this pub-sub and React components. We will do it both ways using HOC and Hooks.

React HOC for dynamic locale switching

HOC has to wrap the existing component and provide ways to read current locale and set desired locale:

// withLocale.js
import React from 'react';
import { getLocale, makeListener, setLocale } from './setLocale';

export default (Cmp) =>
  class WithLocale extends React.Component {
    static displayName = `withLocale(${Cmp.displayName || Cmp.name || 'Component'})`;

    state = { locale: getLocale() };

    listener = null;

    componentDidMount() {
      this.listener = makeListener((locale) => this.setState({ locale }));
    }

    componentWillUnmount() {
      this.listener && this.listener();
    }

    render() {
      return <Cmp currentLocale={this.state.locale} setLocale={setLocale} {...this.props} />;
    }
  };

Usage:

// App.js
import React, { memo } from 'react';
import Switcher from './Switcher';
import withLocale from './withLocale';
import lang from './lang/en-US';

export default withLocale(
  memo(({ currentLocale }) => (
    <div>
      {lang.HELLO_WORLD} ({currentLocale || 'default'})
      <br />
      <Switcher />
    </div>
  )),
);

Or in the above described Gate component:

// Gate.js
import React from 'react';
import withLocale from './withLocale';

export default withLocale(
  class Gate extends React.Component {
    state = { loading: true };

    mounted = false;

    async componentDidMount() {
      this.mounted = true;
      const { currentLocale, setLocale } = this.props;
      await setLocale(currentLocale);
      if (this.mounted) this.setState({ loading: false });
    }

    componentWillUnmount() {
      this.mounted = false;
    }

    render() {
      const { loading } = this.state;
      const { children } = this.props;
      return children(loading);
    }
  },
);

React Hooks for dynamic locale switching

Same thing can be achieved with new React Hooks, but in a way less verbose way:

// useLocale.js
import { useState, useEffect } from 'react';
import { getLocale, makeListener, setLocale } from './setLocale';

export const useLocale = () => {
  const [locale, setLocaleState] = useState(getLocale());
  useEffect(() => makeListener((locale) => setLocaleState(locale)), []);
  return [locale, setLocale];
};

Usage:

// Switcher.js
import React from 'react';
import { useLocale } from './useLocale';

export default () => {
  const [currentLocale, setLocale] = useLocale();
  return (
    <select onChange={(e) => setLocale(e.target.value)} value={currentLocale}>
      <option value="">En</option>
      <option value="fr-FR">Fr</option>
    </select>
  );
};

Conclusion

At this point we've achieved all the requirements we've set in the beginning of the article, let's recap what this setup is capable of:

  1. No frameworks
  2. Only Webpack/Parcel or any other ES-compatible bundler is needed, we used only standard documented ES features, no hacks
  3. Key-based translation files, with autocompletion, code navigation, static analysis works fine
  4. JS (or JSON) files can be shipped to vendors as is, no preprocessing needed
  5. Locale can be switched in real time without page reload
  6. Locale and UI are synchronized across all open tabs

And all of that using less that 100 LOC.