㊗️ 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:
Use no frameworks
Use standard techniques, no hacks
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
Manageability on a scale: resource files has to be duplicate-free, statically parseable, shippable to localization vendors
Ability to change locale on the fly in runtime without page reloads
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'}
Copy
JSON
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'}
Copy
JSON
// fr-FR.json {HELLO_WORLD: 'Bonjour le monde'}
Copy
JSON
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' };
Copy
JSON
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>);
Copy
JavaScript
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
Copy
JavaScript
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>) } }
Copy
JavaScript
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 } }
Copy
JavaScript
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; };
Copy
JavaScript
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') );
Copy
JavaScript
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);
Copy
JavaScript
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); };
Copy
JavaScript
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}/>; } }
Copy
JavaScript
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> )));
Copy
JavaScript
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); } });
Copy
JavaScript
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]; };
Copy
JavaScript
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> ); };
Copy
JavaScript
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:
No frameworks
Only Webpack/Parcel or any other ES-compatible bundler is needed, we used only standard documented ES features, no hacks
Key-based translation files, with autocompletion, code navigation, static analysis works fine
JS (or JSON) files can be shipped to vendors as is, no preprocessing needed
Locale can be switched in real time without page reload
Locale and UI are synchronized across all open tabs
And all of that using less that 100 LOC.