React Intl (Localization)

There are many localization libraries and strategies available, so why react-intl? Simple, because Yahoo said so. What else do you need?! Joke aside, what is the issue programmers have with localization? The issue is that there is no separation between the responsibility of a programmer and the people who handles translation. Programmers wants to focus on their code structuring logic and most definitely don't want to think about how to implement 15 different languages.

Why don't we use one key-value pair file per language?

Yes you can have a file holding all key-value pairs of one language's translations, but there are two issues in this.

ONE: when you pass this key-value pair file to a translator, how in heaven is he/she going to tell the context of this key-value word? 'Open Sesame' in a button in a page might not be directly worded as 'Open Sesame' in a title in another page. react-intl resolves this by storing along with the id and the defaultMessage, a description field to specifically tell the translator more details about the word's origin and context.

TWO: When you move a component from one project to another, you'll have to redefine all locale fields again. By using react-intl, you can define the messages from within the component, and ultimately keeping the component contained and reusable.

Conventions

id: {context}.{type}.{fieldName} - must be all lowercase

  • context describes the environment it is in (ie. form, component name)
  • type describes the usage of it (ie. fieldtitle (for form), validation (for form), heading, message, button)
  • fieldName describes the specific identifier in this context.type
  • examples:

    • form.validation.required

    • form.fieldtitle.name

    • form.button.clear

defaultMessage: the message to be displayed in the default language (default language is defined by team)

description: anything you can describe that'll help the translator have a more concise understanding of what this field does

Let's start!

In containers/RecipeForm, create a file called messages.js with the following code (snippet: imsg):

/* @flow */

import {defineMessages} from 'react-intl';

export default defineMessages({
  validationFieldRequired: {
    id: 'form.validation.required',
    defaultMessage: 'This is required',
    description: 'form field required'
  },
  validationFieldInvalidNumber: {
    id: 'form.validation.invalidnumber',
    defaultMessage: 'This field needs to be a number',
    description: 'form field invalid number'
  },
  validationFieldInvalid: {
    id: 'form.validation.invalid',
    defaultMessage: 'This value is invalid',
    description: 'form field invalid'
  },
  btnAddRecipe: {
    id: 'form.button.addrecipe',
    defaultMessage: 'Add Recipe',
    description: 'submit button for recipe form'
  },
  btnClear: {
    id: 'form.button.clear',
    defaultMessage: 'Clear',
    description: 'form clear button'
  },
  btnAddIngredient: {
    id: 'form.button.addingredient',
    defaultMessage: 'Add Ingredient',
    description: 'a button for appending an ingredient'
  },
  btnRemoveIngredient: {
    id: 'form.button.removeingredient',
    defaultMessage: 'Remove Ingredient',
    description: 'a button for removing current ingredient'
  },
  formIngredientCount: {
    id: 'form.title.ingredientcount',
    defaultMessage: 'Ingredient #{count}',
    description: 'form ingredient array count'
  },
  formName: {
    id: 'form.fieldtitle.name',
    defaultMessage: 'Name',
    description: 'form title for name'
  },
  formIngredientName: {
    id: 'form.fieldtitle.ingredientname',
    defaultMessage: 'Name',
    description: 'form title for ingredient name'
  },
  formIngredientAmount: {
    id: 'form.fieldtitle.ingredientamount',
    defaultMessage: 'Amount',
    description: 'form title for ingredient amount'
  },
  formIngredientUnit: {
    id: 'form.fieldtitle.ingredientunit',
    defaultMessage: 'Select a unit',
    description: 'form title for ingredient unit'
  },
  formIngredientUnitOptionTeaspoon: {
    id: 'form.fieldtitle.ingredientunit.option.teaspoon',
    defaultMessage: 'Teaspoon(s)',
    description: 'form select option for ingredient unit'
  },
  formIngredientUnitOptionTablespoon: {
    id: 'form.fieldtitle.ingredientunit.option.tablespoon',
    defaultMessage: 'Tablespoon(s)',
    description: 'form select option for ingredient unit'
  },
  formIngredientUnitOptionCup: {
    id: 'form.fieldtitle.ingredientunit.option.cup',
    defaultMessage: 'Cup(s)',
    description: 'form select option for ingredient unit'
  },
  formIngredientUnitOptionPiece: {
    id: 'form.fieldtitle.ingredientunit.option.piece',
    defaultMessage: 'Piece(s)',
    description: 'form select option for ingredient unit'
  }
});

We could just return the object without defineMessages(), but this function call will help react-intl get notify that this needs to be rendered for extraction.

Now let's replace the containers/RecipeForm.js with these messages:

/* @flow */
import React, {Component} from 'react';
import {reduxForm, Field, FieldArray} from 'redux-form';
import {
  TextField,
  SelectField
} from 'redux-form-material-ui';
import {RaisedButton, FlatButton, MenuItem} from 'material-ui';
import {FormattedMessage} from 'react-intl';
import messages from './messages';

const validate = values => {
  const errors = {};

  /*
    The errors object follows the form structure, with _error for global errors.
    In this case, errors can hold three fields:
      - _error
      - name
      - ingredients (array of):
        - name
        - amount
        - unit
  */

  if (!values.name) {
    errors.name = (<FormattedMessage {...messages.validationFieldRequired} />);
  }

  // Loop through each ingredient and validate its name, amount, and unit
  if (values.ingredients) {
    let ingredientsArrayErrors = [];
    values.ingredients.forEach((ingredient, ingredientIndex) => {
      let ingredientErrors = {};
      if (!ingredient.name) {
        ingredientErrors.name = (<FormattedMessage {...messages.validationFieldRequired} />);
      }
      if (isNaN(ingredient.amount)) {
        ingredientErrors.amount = (<FormattedMessage {...messages.validationFieldInvalidNumber} />);
      }
      if (!ingredient.unit || !/^(tsp|tbsp|cup|piece)$/i.test(ingredient.unit)) {
        ingredientErrors.unit = (<FormattedMessage {...messages.validationFieldInvalid} />);
      }

      // if there are any errors, add it to the array of errors
      if (Object.keys(ingredientErrors).length > 0) {
        ingredientsArrayErrors[ingredientIndex] = ingredientErrors;
      }
    });
    if (ingredientsArrayErrors.length > 0) {
      errors.ingredients = ingredientsArrayErrors;
    }
  }

  return errors;
};

// We can create custom fields for redux-form. In here, we are rendering the field for redux-form's FieldArray component
const renderIngredients = ({fields, meta: {touched, error}}) => (
  <div>
    <div>
      <button type="button" onClick={() => fields.push({})}><FormattedMessage {...messages.btnAddIngredient} /></button>
      {touched && error && <span>{error}</span>}
    </div>
    <div>
      {fields.map((ingredient, index) =>
        <div key={index}>
          <button onClick={() => fields.remove(index)}><FormattedMessage {...messages.btnRemoveIngredient} /></button>
          <h4><FormattedMessage {...messages.formIngredientCount} values={{count: index + 1}} /></h4>
          <div>
            <Field name={`${ingredient}.name`} component={TextField} floatingLabelText={<FormattedMessage {...messages.formIngredientName} />} />
          </div>
          <div>
            <Field name={`${ingredient}.amount`} type="number" min="0" step={0.25} defaultValue="1" component={TextField} floatingLabelText={<FormattedMessage {...messages.formIngredientAmount} />} />
          </div>
          <div>
            <Field name={`${ingredient}.unit`} component={SelectField} hintText={<FormattedMessage {...messages.formIngredientUnit} />}>
              <MenuItem value="tsp" primaryText={<FormattedMessage {...messages.formIngredientUnitOptionTeaspoon} />} />
              <MenuItem value="tbsp" primaryText={<FormattedMessage {...messages.formIngredientUnitOptionTablespoon} />} />
              <MenuItem value="cup" primaryText={<FormattedMessage {...messages.formIngredientUnitOptionCup} />} />
              <MenuItem value="piece" primaryText={<FormattedMessage {...messages.formIngredientUnitOptionPiece} />} />
            </Field>
          </div>
        </div>
      )}
    </div>
  </div>
);

class RecipeForm extends Component {
  static propTypes = {

  };

  render() {
    const {error, handleSubmit, pristine, reset, submitting} = this.props;
    return (
      <form onSubmit={handleSubmit}>
      {error && <strong>{error}</strong>}
        <div>
          <Field name="name" component={TextField} floatingLabelText={<FormattedMessage {...messages.formName} />} />
        </div>
        <br />
        <br />
        <FieldArray name="ingredients" component={renderIngredients}/>
        <br />
        <br />
        <RaisedButton type="submit" disabled={submitting} primary={true}><FormattedMessage {...messages.btnAddRecipe} /></RaisedButton>
        <FlatButton disabled={pristine || submitting} onClick={reset}><FormattedMessage {...messages.btnClear} /></FlatButton>
      </form>
    );
  }
}

export default reduxForm({
  form: 'Recipe',
  validate
})(RecipeForm);

Although I've only used FormattedMessage here, there are other fields available from react-intl. Take a look at this link for more details: https://github.com/yahoo/react-intl/wiki#the-react-intl-module

You're done, developers! Hurray!

This page is now ready on the developer's end to make it multilingual. For those Curious George out there, here are the localisation we can add to see the result:

in /messages/_default.js:

  ...
  'form.button.addRecipe': 'Add Recipe',
  'form.button.clear': 'Clear',
  'form.button.addingredient': 'Add Ingredient',
  'form.button.removeingredient': 'Remove Ingredient',
  'form.title.ingredientcount': 'Ingredient #{count}',
  'form.fieldtitle.name': 'Name',
  'form.fieldtitle.ingredientname': 'Name',
  'form.fieldtitle.ingredientamount': 'Amount',
  'form.fieldtitle.ingredientunit': 'Select a Unit',
  'form.fieldtitle.ingredientunit.option.teaspoon': 'Teaspoon(s)',
  'form.fieldtitle.ingredientunit.option.tablespoon': 'Tablespoon(s)',
  'form.fieldtitle.ingredientunit.option.cup': 'Cup(s)',
  'form.fieldtitle.ingredientunit.option.piece': 'Piece(s)'

in /messages/zh_CN.js:

  ...
  'form.validation.required': '这项目是必需填写的',
  'form.validation.invalidnumber': '这项目是无效数字',
  'form.validation.invalid': '这项目是无效',
  'form.button.addRecipe': '添加食谱',
  'form.button.clear': '清除',
  'form.button.addingredient': '添加成分',
  'form.button.removeingredient': '去除成分',
  'form.title.ingredientcount': '成分#{count}',
  'form.fieldtitle.name': '名称',
  'form.fieldtitle.ingredientname': '名称',
  'form.fieldtitle.ingredientamount': '多少',
  'form.fieldtitle.ingredientunit': '选择一个单位',
  'form.fieldtitle.ingredientunit.option.teaspoon': '茶匙',
  'form.fieldtitle.ingredientunit.option.tablespoon': '汤匙',
  'form.fieldtitle.ingredientunit.option.cup': '杯',
  'form.fieldtitle.ingredientunit.option.piece': '片'

in /messages/zh_HK.js

  ...
  'form.validation.required': '這項目是必需填寫的',
  'form.validation.invalidnumber': '這項目是無效數字',
  'form.validation.invalid': '這項目是無效',
  'form.button.addRecipe': '添加食譜',
  'form.button.clear': '清除',
  'form.button.addingredient': '添加成分',
  'form.button.removeingredient': '去除成分',
  'form.title.ingredientcount': '成分#{count}',
  'form.fieldtitle.name': '名稱',
  'form.fieldtitle.ingredientname': '名稱',
  'form.fieldtitle.ingredientamount': '多少',
  'form.fieldtitle.ingredientunit': '選擇一個單位',
  'form.fieldtitle.ingredientunit.option.teaspoon': '茶匙',
  'form.fieldtitle.ingredientunit.option.tablespoon': '湯匙',
  'form.fieldtitle.ingredientunit.option.cup': '杯',
  'form.fieldtitle.ingredientunit.option.piece': '片'

Test Result

If you don't have api and web running, hit $ npm start on both. In the browser, go to http://localhost:3000/recipes/create. Click on different locales at the top-left corner of the page to see the different languages in action.

Example Repo

https://bitbucket.org/httpeace/react-node-example/src/b2d5d66f9002?at=05-react-intl

results matching ""

    No results matching ""