A different approach for localizing react.js app

Howdy!

I’ve heard you use ReactJS? Good! I also heard you want to localize your application? That’s great! Well proper localization is hard, there are many js libraries for client side localization as well as yahoo/react-intl that provides localized react components based on JavaScript Intl API.

I my self use es6 syntax and avoid using mixins, because mixins are dead (and well, because there is no easy way to integrate mixins into es6 syntax). So react-intl didn’t work for me. But damn I wanted those localized numbers! In this article I’m gonna show a different (not bad neither good, just different) approach on how I did it.

Localize all the things!!

I do like the idea of Intl API, why reinvent the wheel when we have 51M of compressed localized data with support for dates, numbers, currencies and messages. So we do have jquery/globalize on client side to use all this data. The question is: How do I use it in a react.js application?

Well, here is the general idea

  1. We need a way to identify user locale. Either we can guess it from the browser or (a preferred method, in my opinion) let the user choose his locale.
  2. Once we know the locale, we need to load the needed cldr json data (we can load everything, or just the parts we really need) into Globalize.
  3. ….
  4. PROFIT!!!!

Simple. Yet a bit complicated.


One thing I disliked in react-intl is that each component knew about the locale data. If you have a nested tree of: App => Dashboard => SalesWidget => FormattedMoney, even though the locale data is needed only in FormattedMoney component, its passed to all the child components of App (react-intl handled that automatically using mixins). I offer a different solution. Locale data is no more than just… data! And data should be stored inside… yes right a Store!

Show me the code!

Note: I use alt as my flux implementation. Ideally my locale info should be fetched from the user, but since I do a MVP, I omitted this step.

Application component issues a UserFetch action that goes to the API and fetches the user details. Locale store listen to UserFetchCompleted action and then gets the locale from the user, and loads the needed cldr data.

Here is how it looks

handleSetupStarted() {
this.waitFor(ProfileStore.dispatchToken);

let locale = 'en', //@TODO get from profile
self = this;
request({url: CLDR_URL + 'supplemental.json', type: 'json'})
.then(function (supplemental) {
request({url: CLDR_URL + locale + '.json', type: 'json'})
.then(function (localeData) {
self.handleSetupFinished(locale, supplemental, localeData);
})
.fail(self.handleSetupFailed);
})
.fail(self.handleSetupFailed);
}
handleSetupFinished(locale, supplemental, localeData) {
Globalize.load(supplemental, localeData);
this.setState({translator: Globalize(locale)});
}

(Yes, I know, I do an ajax call from the store. I couldn’t find a better way to do it. For some reason I wans’t able to dispatch an action inside the ProfileStore once the user data loaded because I got this weird message of: Dispatch.dispatch(…): Cannot dispatch in the middle of a dispatch. and was lazy to investigate on how to fix it. Ideally this should be inside an action or an alt term called source. I’ll figure it out later).

See, the handleSetupStarted waits for the ProfileStore to finish loading the user, and once its done, we get its locale and load 2 files: supplemental.json (which is a common set of data for every locale) and en.json (which is data related to the en locale). Once it finished loading, it creates a new instance of Globalize.

Here is an example of a LocalizedMoney component

class Money extends React.Component {
constructor(props) {
super(props);
this.state = {
locale: LocaleStore.getState()
}
}

render() {
let amount = parseFloat(this.props.children),
translator = this.state.locale.translator,
cls = classNames({
money: true,
positive: amount >= 0,
negative: amount < 0
});

return (
<div className={cls}>
{translator.formatCurrency(amount, this.props.currency)}
</div>
);
}
}

And a usage example

render() {
return (
<FormattedMoney currency="USD">1337.93</FormattedMoney>
);
}

See! No need to pass the locale data from the Application component to the LocalizedMoney component! LocalizedMoney just uses the Locale Store. You can even setup listener on localized components, to listen to changes on the Locale store, and when the user fires a Change Locale Action, you can change the locale on the fly without reloading the page. But I wouldn’t mind reloading the page. Locale is not something you change twenty times a day.

You can as well implement caching for different formatters inside the LocaleStore as well, and instead of using the translator (which is just an instance of Globalize) directly, you can use something like

LocaleStore.getCurrencyFormatter(“USD”).format(123.45);

Which internally looks like (pseudo code):

getCurrencyFormatter(currencyCode) {
if(!this.state.currencyFormatters[currencyCode]) {
this.state.currencyFormatters[currencyCode] = this.state.translator.currencyFormatter(currencyCode);
}
return this.state.currencyFormatters[currencyCode];
}

Note: formatter identified not only by currency, but by options as well. You can have two USD formatters: One that shows the number as

$1,000.00

And another one as

1,000.00 US Dollars

In short: RTFM.

A note about CLDR shipping

The process of shipping the locale data should be a build step of your application. There is cldr-data-downloader. Its a node module to download the CLDR data from unicode.org.

I then use gulp to bundle the supplemental (common) and locale based data into merged json file.

Less talk, more code:

gulp.task('cldr:download', function (cb) {
cldrDataDownloader('http://www.unicode.org/Public/cldr/' + CLDR_VERSION + '/json.zip', cldrPath, function (err) {
if (err) return cb(err);
cb();
});
});

gulp.task('cldr:bundle:supplemental', function () {
var files = ['likelySubtags.json', 'currencyData.json', 'numberingSystems.json', 'plurals.json', 'ordinals.json'];
return gulp
.src(files.map(function (f) {
return path.join(cldrPath, 'supplemental', f);
}))
.pipe(extend('supplemental.json'))
.pipe(gulp.dest(path.join(distDir, 'cldr')));
});

gulp.task('cldr:bundle:locale', function () {
var files = ['currencies.json', 'numbers.json'];

var tasks = locales.map(function (locale) {
return gulp
.src(files.map(function (f) {
return path.join(cldrPath, 'main', locale, f);
}))
.pipe(extend(locale + '.json'))
.pipe(gulp.dest(path.join(distDir, 'cldr')));
});
return gulpMerge(tasks);
});

At this point I need only currency formatting, so I use the required files in both supplemental bundle and locale bundle. jquery/globalize actually giving a nice table that shows, what json files from the cldr you need in order to use different localization modules.

A final note

For now I am happy with this approach. In my opinion it decouples the components from the actual localization implementation. Components stays reusable even if you decide to switch to a different localization implementation. Your store can expose an API like the getCurrencyFormatter() I showed earlier, and actual implementation can be based on Intl, or your own. FormattedMoney component doesn’t care.

Intl API is supported in all major browsers and those that do not support it, can be polyfilled. Of course Intl API can be an overkill for most applications, especially if all you want is translating strings. But if your application requires displaying localized numbers, dates, times — I think there is no better solution other than Intl API.

Best of luck and may the force be with you.

Show your support

Clapping shows how much you appreciated Dmitry Kudryavtsev’s story.