i18n utilities for React handling translations, formatting, and more.
$ yarn add @shopify/react-i18n
This library requires a provider component which supplies i18n details to the rest of the app, and coordinates the loading of translations. Somewhere near the "top" of your application, render a I18nContext.Provider
component. This component accepts an I18nManager
as the value
prop, which allows you to specify the following global i18n properties:
locale
: the current locale of the app. This is the only required option.fallbackLocale
: the locale that your component’s will use in any of their fallback translations. This is used to avoid unnecessarily serializing fallback translations.country
: the default country to use for country-aware formatting.timezone
: the default timezone to use for timezone-aware formatting.currency
: the default currency to use for currency-aware formatting.pseudolocalize
: whether to perform pseudolocalization on your translations.onError
: a callback to use when recoverable i18n-related errors happen. If not provided, these errors will be re-thrown wherever they occur. If it is provided and it does not re-throw the passed error, the translation or formatting that caused the error will return an empty string. This function will be called with the error object.
import {I18nContext, I18nManager} from '@shopify/react-i18n';
const locale = 'en';
const i18nManager = new I18nManager({
locale,
onError(error) {
Bugsnag.notify(error);
},
});
export default function App() {
return (
<I18nContext.Provider value={i18nManager}>
{/* App contents */}
</I18nContext.Provider>
);
}
Components must connect to the i18n context in order to get access to the many internationalization utilities this library provides. You can use the useI18n
hook to access i18n
in your component:
import React from 'react';
import {EmptyState} from '@shopify/polaris';
import {useI18n} from '@shopify/react-i18n';
export default function NotFound() {
const [i18n] = useI18n();
return (
<EmptyState
heading={i18n.translate('NotFound.heading')}
action={{content: i18n.translate('Common.back'), url: '/'}}
>
<p>{i18n.translate('NotFound.content')}</p>
</EmptyState>
);
}
The hook also returns a ShareTranslations
component. You can wrap this around a part of the subtree that should have access to this component’s translations.
Note:
ShareTranslations
is not guaranteed to re-render when your i18n object changes. If you renderShareTranslations
inside of a component that might block changes to children, you will likely run into issues. To prevent this, we recommend thatShareTranslations
should be rendered as a top-level child of the component that usesuseI18n
.
import React from 'react';
import {Page} from '@shopify/polaris';
import {useI18n} from '@shopify/react-i18n';
interface Props {
children: React.ReactNode;
}
export default function ProductDetails({children}: Props) {
const [i18n, ShareTranslations] = useI18n();
return (
<ShareTranslations>
<Page title={i18n.translate('ProductDetails.title')}>{children}</Page>
</ShareTranslations>
);
}
@shopify/react-i18n
also provides the withI18n
decorator as a migration path towards the useI18n
hook, or for use with class components. Unlike the hook version, components using the withI18n
decorator always share their translations with the entire tree.
import React from 'react';
import {EmptyState} from '@shopify/polaris';
import {withI18n, WithI18nProps} from '@shopify/react-i18n';
export interface Props {}
type ComposedProps = Props & WithI18nProps;
class NotFound extends React.Component<ComposedProps> {
render() {
const {i18n} = this.props;
return (
<EmptyState
heading={i18n.translate('NotFound.heading')}
action={{content: i18n.translate('Common.back'), url: '/'}}
>
<p>{i18n.translate('NotFound.content')}</p>
</EmptyState>
);
}
}
export default withI18n()(NotFound);
The provided i18n
object exposes many useful methods for internationalizing your apps. You can see the full details in the i18n
source file, but you will commonly need the following:
formatNumber()
: formats a number according to the locale. You can optionally pass anas
option to format the number as a currency or percentage; in the case of currency, thedefaultCurrency
supplied to the i18nI18nContext.Provider
component will be used where no custom currency code is passed.formatCurrency()
: formats a number as a currency according to the locale. Its behaviour depends on theform:
option.- if
form: 'short'
is given, then a possibly-ambiguous short form is used, consisting of the bare symbol if the currency has a symbol, or the ISO 4217 code if there is no symbol for that currency. Examples:CHF 1,25
,€ 1,25 EUR
,OMR 1.250
,$ 1.25 USD
- if
form: 'explicit'
is given, then the result will be the same as forshort
, but will append the ISO 4217 code if it is not already present - if
form: 'auto'
is given, thenexplicit
will be selected if thecurrency
option does not match thedefaultCurrency
, otherwiseshort
is selected. If eithercurrency
ordefaultCurrency
is not defined thenshort
is selected. - if
form:
is not given, then behaviour reverts to the legacy (deprecated)formatCurrency()
, which is a convenience function that simply auto-assigns theas
option tocurrency
and callsformatNumber()
. Note that this will resembleform: 'short'
, but will sometimes extend the symbol with extra information depending on the browser's implementation ofIntl.NumberFormat
and the locale in use. For example,formatCurrency(1.25, {currency: 'CAD'})
may return$ 1.25
, or it might returnCA$ 1.25
.
- if
formatPercentage()
: formats a number as a percentage according to the locale. Convenience function that simply auto-assigns theas
option topercent
and callsformatNumber()
.formatDate()
: formats a date according to the locale. ThedefaultTimezone
value supplied to the i18nI18nContext.Provider
component will be used when no customtimezone
is provided. Assign thestyle
option to aDateStyle
value to use common formatting options.DateStyle.Long
: e.g.,Thursday, December 20, 2012
DateStyle.Short
: e.g.,Dec 20, 2012
DateStyle.Humanize
: Adheres to Polaris guidelines for dates with times, e.g.,Just now
,3 minutes ago
,4 hours ago
,10:35 am
,Yesterday at 10:35 am
,Friday at 10:35 am
, orDec 20 at 10:35 am
, orDec 20, 2012
DateStyle.Time
: e.g.,11:00 AM
weekStartDay()
: returns start day of the week according to the country.getCurrencySymbol()
: returns the currency symbol according to the currency code and locale.formatName()
: formats a name (first name and/or last name) according to the locale. e,gformatName('John', 'Smith')
will returnJohn
in Germany andSmith様
in JapanformatName('John', 'Smith', {full: true})
will returnJohn Smith
in Germany andSmithJohn
in Japan
ordinal()
: formats a number as an ordinal according to the locale, e.g.1st
,2nd
,3rd
,4th
hasEasternNameOrderFormatter()
: returns true when an eastern name order formatter corresponding to the locale/language exists.
Most notably, you will frequently use i18n
’s translate()
method. This method looks up a key in translation files that you supply based on the provided locale. This method is discussed in detail in the next section.
The most commonly-used feature of the @shopify/react-i18n
library is looking up translations. In this library, translations are provided for the component that need them, and are available for ancestors of the component. This allows applications to grow while keeping translations manageable, makes it clearer where to add new translations, and follows Shopify’s principle of isolation over integration by collocating translations with all other component assets.
Translations are provided using two keys in the withI18n
decorator:
fallback
: a translation file to use when translation keys are not found in the locale-specific translation files. These will usually be your English translations, as they are typically the most complete.translations
: a function which takes the locale and returns one of: nothing (no translations for the locale), a dictionary of key-value translation pairs, or a promise of one of the above. Thetranslations
function can also throw andreact-i18n
will handle the situation gracefully. Alternatively, you can pass an object where the keys are locales, and the values are either translation dictionaries, or promises for translation dictionaries.
We recommend that colocate your translations files in a ./translations
directory and that you include an en.json
file in that directory as your fallback. We give preferential treatment to this structure via a babel plugin that will automatically fill in the arguments to useI18n
/ withI18n
for you.
If you provide any of the above options, you must also provide an id
key, which gives the library a way to store the translation dictionary. If you're using the babel plugin, this id
will the automatically generated based on the relative path to your component from your project's root directory.
Here’s the example above with component-specific translations:
import React from 'react';
import {EmptyState} from '@shopify/polaris';
import {useI18n} from '@shopify/react-i18n';
import en from './locales/en.json';
import fr from './locales/fr.json';
export default function NotFound() {
const [i18n] = useI18n({
id: 'NotFound',
fallback: en,
translations(locale) {
if (locale === 'en') {
return en;
} else if (locale === 'fr') {
return fr;
}
},
});
return (
<EmptyState
heading={i18n.translate('NotFound.heading')}
action={{content: i18n.translate('NotFound.action'), url: '/'}}
>
<p>{i18n.translate('NotFound.content')}</p>
</EmptyState>
);
}
// NotFound/components/en.json
{
"NotFound": {
"heading": "Page not found",
"action": "Back",
"content": "The page you were looking for could not be found. Please check the web address for errors and try again."
}
}
As shown above, we recommend scoping the translation file to the name of the component to prevent potential naming conflicts resulting from typos in the keys you use.
A few other details are worth noting about translation loading and lookup:
- Your
translations
function can be called several times for a given locale. If, for example, the locale isen-CA
, your function will be called withen-CA
anden
, which allows you to load country-specific variations for translations. - The
i18n
object supplied to a given component can reference translations at any level of depth using a keypath (for example,NotFound.heading
in the code above). It can also reference translations in parent components; use this to include common translations around a component that contains most of your application. - When
translate
is called, it looks up translations in the following order: explicit translations provided by the component’stranslations
function, then translations from thefallback
for the component, then the same process in every parent component, from bottom to top, that are also connected withi18n
. - In the case of asynchronous translations, your component will only be able to look up translations from its (and ancestors’)
fallback
translation dictionaries until the translations have loaded.
Replacements can be provided as key-value pairs following the translation key. Your translations should reference the relevant key names, surrounded by a single set of curly braces:
// Assuming a dictionary like:
// {
// "MyComponent": {
// "details": "See {link}"
// }
// }
i18n.translate('MyComponent.details', {link: <Link />});
Replacements can by plain strings or React elements. When a React element is found, the resulting value will be a ReactNode
, which can be used as the children of other React components.
For dynamically-generated translation keys, you can use the scope
option to specify a partial keypath against which the key is looked up:
// Assuming a dictionary like:
// {
// "MyComponent": {
// "option": {
// "valueOne": "One",
// "valueTwo": "Two"
// }
// }
// }
i18n.translate(key, {scope: 'MyComponent.option'});
// or
i18n.translate(key, {scope: ['MyComponent', 'option']});
It may be necessary to check dynamic keys. You can use the translationKeyExists
method to do so:
const keyExists = i18n.translationKeyExists(key);
if (keyExists) {
return i18n.translate(key, {scope: ['MyComponent', 'option']});
}
@shopify/react-i18n
handles pluralization similarly to Rails’ default i18n utility. The key is to provide the plural-dependent value as a count
variable. react-i18n
then looks up the plural form using Intl.PluralRules
and, within the keypath you have specified for the translation, will look up a nested translation matching the plural form:
// Assuming a dictionary like:
{
"MyComponent": {
"searchResult": {
"one": "{count} widget found",
"other": "{count} widgets found"
}
}
}
i18n.translate('MyComponent.searchResult', {count: searchResults});
As noted above, this functionality depends on the Intl.PluralRules
global. If this does not exist for your environment, we recommend including the intl-pluralrules
polyfill or included import '@shopify/polyfills/intl';
from @shopify/polyfills
.
We also recommend to have the {count}
variable in all of your keys as some languages can use the key "one"
when the count is zero
for example. See MDN docs on Localization and Plurals.
By default, {count}
will be automatically formatted as a number. If you want to format the variable differently, you can simply pass it in another variable.
// Assuming a dictionary like:
{
"MyComponent": {
"searchResult": {
"one": "{formattedCount} widget found",
"other": "{formattedCount} widgets found"
}
}
}
i18n.translate('MyComponent.searchResult', {
count: searchResults,
formattedCount: i18n.formatNumber(searchResults),
});
If you need to access the subtree of your translations, you can use i18n.getTranslationTree
to get all subtranslations:
// Assuming a dictionary like:
{
"MyComponent": {
"countries": {
"CA": "Canada",
"FR": "France",
"JP": "Japan"
}
}
}
i18n.getTranslationTree('MyComponent.countries');
// Will return
// {
// "CA": "Canada",
// "FR": "France",
// "JP": "Japan"
// }
When rendering internationalized React apps on the server, you will want to extract the translations and rehydrate them on the client if any translations are loaded asynchronously. Not doing so would cause the server and client markup to differ, resulting in a full re-render.
We recommend you to use @shopify/react-html
with @shopify/react-i18n-universal-provider
to serialize the extracted translations and rehydrate them on the client.
import {
Html,
render,
Serialize,
HtmlContext,
HtmlManager,
} from '@shopify/react-html/server';
import {I18nManager} from '@shopify/react-i18n';
import {extract} from '@shopify/react-effect/server';
function App({locale}: {locale?: string}) {
return (
<I18nUniversalProvider locale={locale}>
{/* App contents */}
</I18nContext.Provider>
);
}
const app = <App locale='en' />;
const htmlManager = new HtmlManager();
await extract(element, {
decorate(app) {
return (
<HtmlContext.Provider value={htmlManager}>{app}</HtmlContext.Provider>
);
},
});
const html = render(
<Html manager={htmlManager}>
{app}
</Html>,
);
This package includes a plugin for Babel that auto-fills useI18n
's or withI18n
's arguments from an adjacent translations folder. The Babel plugin is exported from the @shopify/react-i18n/babel
entrypoint:
// babel.config.js
{
plugins: [
['@shopify/react-i18n/babel'],
],
}
This plugin will look for an adjacent translations folder containing, at minimum, an en.json
file (the default locale). It will then iterate over each reference to the useI18n
hook or withI18n
decorator and, if the reference is a call expression with no arguments, and inject the appropriate arguments.
// Within MyComponent.tsx:
useI18n();
// Becomes:
import _en from './translations/en.json';
useI18n({
id: 'MyComponent_<hash>',
fallback: _en,
async translations(locale) {
const dictionary = await import(
/* webpackChunkName: "MyComponent_<hash>-i18n", webpackMode: "lazy-once" */ `./translations/${locale}.json`
);
return dictionary;
},
});
Because babel-loader
's cache is based on a component's source content hash, newly added translation files will not invalidate the component's Babel cache. To combat this, run the generateTranslationIndexes
function before building, and configure the plugin to use its from-generated-index
mode.
The generator will look for any translations
folders and generate an array of local ids in translations/index.js
based on the {locale}.json
files found. We recommend that you add **/translations/index.js
to .gitignore
to make sure the generated files are not checked-in.
// webpack.config.js
module.exports = {
resolve: {
extensions: ['.js', '.jsx'],
},
module: {
rules: [
{
test: /\.jsx?$/,
use: [
{
loader: 'babel-loader',
options: {
plugins: [
'@babel/plugin-syntax-dynamic-import',
['@shopify/react-i18n/babel', {mode: 'from-generated-index'}],
],
},
},
],
},
],
},
};
// generate-translations.js
const {
generateTranslationIndexes,
} = require('@shopify/react-i18n/generate-index');
generateTranslationIndexes();
webpack(require(./webpack.config.js));
For large applications, even asynchronously loaded translations can significantly degrade the user experience:
- Bundlers like webpack have to embed kilobytes of data to track each translation import
- Users not using the "default" language have to download kilobytes of translations for every language
To avoid this, it is possible to build versions of app with specific locale translations embedded directly in JavaScript. To achieve this, run the Babel plugin in from-dictionary-index
mode:
// webpack.config.js
{
plugins: [
['@shopify/react-i18n/babel', {mode: 'from-dictionary-index'}],
],
}
Then generate translations/index.js
files containing specific locale data using the @shopify/react-i18n/generate-dictionaries
helper. e.g., the following code generates three versions of an application with English, French, and German content using webpack.
// generate-translations.js
const {
generateTranslationDictionaries,
} = require('@shopify/react-i18n/generate-dictionaries');
// Build English app.
await generateTranslationDictionaries(['en']);
webpack(require(./webpack.config.js));
// Build French app.
await generateTranslationDictionaries(['fr'], {fallbackLocale: 'en'});
webpack(require(./webpack.config.js));
// Build German app.
await generateTranslationDictionaries(['de'], {fallbackLocale: 'en'});
webpack(require(./webpack.config.js));
If you want your default locale to be something else than English because it's not your primary locale, you can pass the defaultLocale
option to the babel plugin:
// webpack.config.js
{
plugins: [
['@shopify/react-i18n/babel', {defaultLocale: 'fr'}],
],
}
Why another i18n library? Why not just use <react-intl | react-i18next> etc?
These libraries are excellent, and we may well use parts of them under the hood for this project. However, we wanted to add a Shopify-specific layer that cleanly exposes some features we feel are non-negotiable:
- Per-component management of translations, to avoid the ever-growing translation files that hurt our largest apps.
- Asynchronous loading of translation files, so that we can scale the number of supported languages without increasing bundle sizes.
- An API for translations that feels consistent with Rails’ default i18n utilities.
- Exposing currency and datetime formatting utilities that automatically follow the Polaris conventions.
Additional details on why we built our own package, and on specifics of parts of this package’s API, are available in the original proposal.