Once upon a time way back, I had to modify an existing eCommerce application to support multiple locales. I remember this being a super fun project to undertake, and it to expand adoption by a customer base for the client that wasn't confined to just English speakers.
Adding an additional language really allowed the client to reach a broader audience since it already employed some native spanish speakers. Some business was being done with existing applications however it was more of a limited role and customers weren't able to take full advantage of the rich content that been curated by the marketing teams.
Since both languages were left to right, the page designs remained uncomplicated and didn't need to be reorganized.
I found there was a similar implementation in another application in use by the client. This application basically used a helper function that was passed as a prop to the main Redux container.
import GetLocaleString from './helpers/GetLocaleString' import React from 'react' import { bindActionCreators } from 'redux' import { connect } from 'react-redux' import * as Actions from './actions' import Container from './containers/Container' import ReactComponent from './components/ReactComponent' class Container extends React.Component { render() { const { dispatch, locale, route } = this.props const boundActions = bindActionCreators(Actions, dispatch) return <ReactComponent getString={getString} {...this.props} {...boundActions} /> } } const mapStateToProps = (state) => { return state } export default connect(mapStateToProps)(Container)
This helper was then used at each locale callsite to lookup a string key and provide the readable string.
import React from 'react' const ReactComponent = (p) => ( <div className='rental-details'> {p.getString('someTitleKey')} </div> ) export default ReactComponent
export const strings = { en: { someTitleKey: "Some text here", }, es: { someTitleKey: '(Spanish translation of) Some text here', }, }
The immediate downside I saw to this was the helper function would need to keep getting passed down to every component using it. Obviously this wasn't going to work.
I did however like the two objects of localized text where the localization key was just the locale.
After seeing what was available close by, I looked around the internet and saw how others did it.
I found there were also others using lots of helper functions performing similar lookups to the above pattern.
Lots of people seemed to gravitate towards this drop in module called i18n. I definitely knew something like this wasn't going to work, even with a large amount of pain of suffering as is usually the case with these types of packages.
import React from 'react'; import ReactDOM from 'react-dom'; import Container from './Container'; import { I18nextProvider } from 'react-i18next'; import i18n from './i18n' ReactDOM.render( <I18nextProvider i18n={i18n}> <Container /> </I18nextProvider> , document.getElementById('app'))
import React from 'react'; import { withTranslation, Trans } from 'react-i18next' class Container extends React.Component { constructor(props) { super(props) this.state = { value: "en" } } onLanguageHandle = (event) => { let newLang = event.target.value; this.setState({value: newLang}) this.props.i18n.changeLanguage(newLang) } renderRadioButtons = () => { return ( <div><input checked={this.state.value === 'en'} name="language" onChange={(e) => this.onLanguageHandle(e)} value="en" type="radio" />English <input name="language" value="jp" checked={this.state.value === 'jp'} type="radio" onChange={(e) => this.onLanguageHandle(e)} />Japanese</div> ) } render () { const {t} = this.props console.log('this is', this) return ( <div className="App"> { this.renderRadioButtons() } <h1><Trans>Paragraph</Trans></h1> <table> <tbody> <tr> <td style={{width: '20%'}}>{t('author.title')}</td> <td style={{width: '5%'}}>:</td> <td style={{width: '75%'}}>{t('author.value')}</td> </tr> <tr> <td style={{width: '20%'}}>{t('description.title')}</td> <td style={{width: '5%'}}>:</td> <td style={{width: '75%'}}>{t('description.value')}</td> </tr> </tbody> </table> </div> ); } } export default withTranslation()(App);
{ "translations": { "Paragraph": "Paragraph", "author": { "title": "Author Name", "value": "Tariqul" }, "description": { "title": "description", "value": "Whenever I smell strong tobacco smoke when I'm in an enclosed space such as a room, train, or aircraft, I begin to get angry. There are several reasons. First, medical researchers have shown that secondhand smoke, that is, the smoke from other people's cigarettes, causes cancer and other health problems. If the smoke were car exhaust or burning trash, we would put out the fire and open the windows to get rid of the smoke. Second, it stinks. The smoke drifts away from the smoker and fills the room." } } }
One of the patterns I immediately know I wouldn't like was where a special React component is used to handle the key lookups, and the key to use for lookup is just passed in as a prop.
import { IntlProvider } from 'react-intl' import Cookie from 'js-cookie' const locale = Cookie.get('locale') || 'en'; ReactDOM.render( <IntlProvider locale={locale}> <App /> </IntlProvider>, document.getElementById('app') );
import { FormattedMessage } from 'react-intl' const ReactComponent = (p) => ( <h1><FormattedMessage id="app.hello_world" defaultMessage="Hello World!" description="Hello world header greeting" /></h1> )
{ "app.hello_world": "Hello World!", }
Given that none of these really jumped out at to me as the obvious solution, I looked at what kind of functionality was available through React and Redux.
What I found was that I could create a provider with Redux, and then just place the provider side by side any of the containers and inject a localization context directly into the components as a prop.
This was huge and solved many of the problems faced with the other implementations I mentioned above.
import ReactContainer from './containers/ReactContainer' import configureStore from './store/configureStore' import LocalizationProvider from './providers/Localization' import React from 'react' import { render } from 'react-dom' import { Provider } from 'react-redux' const store = configureStore() const { languageCode } = getSupportedBrowserLocale() render( <Provider store={store}> <LocalizationProvider languageCode={languageCode}> <ReactContainer /> </LocalizationProvider> </Provider>, document.getElementById('app') )
import React from 'react' import * as Actions from '../actions' import { attachLocalizationProxy } from '../helpers' import { bindActionCreators } from 'redux' import { connect } from 'react-redux' import ReactComponent class ReactContainer extends React.Component { render() { const { dispatch } = this.props const boundActions = bindActionCreators(Actions, dispatch) const loc = attachLocalizationProxy(this.context.loc) return <ReactComponent languageCode={this.context.languageCode} loc={loc} {...this.props} {...boundActions} /> } } ReactContainer.contextTypes = { languageCode: PropTypes.string, loc: PropTypes.object, } const mapStateToProps = (state) => { return state } export default connect(mapStateToProps)(ReactContainer)
import React from 'react' import { strings } from '../../constants/Localization' const LocalizationProvider = class extends React.Component { constructor(props) { super() this.state = { languageCode: props.languageCode, loc: strings[props.languageCode], } } getChildContext() { return { languageCode: this.state.languageCode, loc: this.state.loc, } } render() { return this.props.children } } LocalizationProvider.childContextTypes = { languageCode: PropTypes.string, loc: PropTypes.object, } export default LocalizationProvider
const FORMATTED_PHONE = '___FORMATTED_PHONE___' const DAY = '___DAY___' const WEEK = '___WEEK___' const strings = { en: { about: 'About', }, es: { about: 'Acerca de', }, } module.exports = { FORMATTED_PHONE, DAY, WEEK, strings, }
/** * For use in browsers that support "Proxy". Meant to be wrapped by * "attachLocalizationProxy" * * @param {Object} [bootstrap={}] Bootstrap data object * @param {Object} [loc={}] Localization object * @return {Object} Modified localization object */ export const localizationProxyFullSupport = (bootstrap, loc) => { if (typeof Proxy === 'undefined') { throw new Error(`You tried to use "localizationProxyFullSupport" in a browser that does not support "Proxy". Use "localizationProxyShim" instead.`) } const phone = bootstrap.phone || {} const proxy = new Proxy(loc, { get: (receiver, name) => { // Try and get the localized string, else use `name` to show the loc // key, or a textual string since a user could have a non-localized string // being provided let receiverVal = receiver[name] || name if (typeof receiverVal !== 'string') { return receiverVal } if (receiverVal.indexOf(FORMATTED_PHONE) >= 0 && typeof phone.formatted !== 'undefined') { // Format the bootstrapped formatted phone string const regex = new RegExp(FORMATTED_PHONE, 'g') receiverVal = receiverVal.replace(regex, phone.formatted) } if (receiverVal.indexOf(DAY) >= 0) { const regex = new RegExp(DAY, 'g') receiverVal = receiverVal.replace(regex, loc.day) } if (receiverVal.indexOf(WEEK) >= 0) { const regex = new RegExp(WEEK, 'g') receiverVal = receiverVal.replace(regex, loc.week) } return receiverVal }, }) return proxy } /** * For use in browsers that do not support "Proxy". Possibly slightly slower * than if we were to use a Proxy due to having to walk the entire localization * dictionary to build the lookups. Meant to be wrapped by "attachLocalizationProxy" * * @param {Object} [bootstrap={}] Bootstrap data object * @param {Object} [loc={}] Localization object * @return {Object} Modified localization object */ export const localizationProxyShim = (bootstrap, loc) => { const getReceiverVal = (property) => { let receiverVal = loc[property] || property if (typeof receiverVal !== 'string') { return receiverVal } if (receiverVal.indexOf(FORMATTED_PHONE) >= 0 && typeof phone.formatted !== 'undefined') { // Format the bootstrapped formatted phone string const regex = new RegExp(FORMATTED_PHONE, 'g') receiverVal = receiverVal.replace(regex, phone.formatted) } if (receiverVal.indexOf(DAY) >= 0) { const regex = new RegExp(DAY, 'g') receiverVal = receiverVal.replace(regex, loc.day) } if (receiverVal.indexOf(WEEK) >= 0) { const regex = new RegExp(WEEK, 'g') receiverVal = receiverVal.replace(regex, loc.week) } return receiverVal } const phone = bootstrap.phone || {} const proxy = {} Object.keys(loc).forEach((property) => { Object.defineProperty(proxy, property, { get() { return getReceiverVal(property) }, }) }) proxy.dyn = (property) => { return getReceiverVal(property) } return proxy }
One thing I didn't like about this approach was it got difficult when I had to split strings based on the necessary DOM and still maintain meaning, for instance when part of a string is a normal font weight, and a little bit of it is bold text.
If I could do anything different about the project, I would probably find a way to generate the localization keys differently. right now, it's a very unobjective process to take where I just use a substring of whatever text I need to localize and sprinkle that throughout the application. that's all well and good until I need to change the text and the key no longer makes sense.
The thing that started to suck was maintaining the localization file with english and spanish. there is definitely potential to move to more an actual localization file or some type of fast localization cache that would run alongside any page queries. right now, all of the localization is tied up in the javascript file which just adds to the bloat.