FieldNotesFORRESTBLADE.COM ↗

2020-02-144 MIN[react][i18n][tutorial]

english is a prototype

The international dealer locator is live in its first batch of countries now, and I want to write down what building it taught me, because every lesson basically arrived the same way: something I assumed was universal turned out to be an English-speaking habit.

string concatenation is how translations die

The old JSP site built sentences in code, "Showing " + count + " dealers near " + city, and the first thing any translator will tell you is that word order is not portable. German wants the verb somewhere else, and languages you've never shipped will want structures you can't predict from English. Concatenation hardcodes English grammar into your application logic.

The fix is messages as complete sentences with placeholders, in ICU message format, which handles the other landmine, plurals. English has two plural forms. Polish has more, Arabic has more than that.

// locales/en.json
{
  "results.count": "{count, plural, one {# dealer near you} other {# dealers near you}}",
  "results.near": "Dealers near {city}",
  "detail.hours": "Open {day} at {time}"
}
import { FormattedMessage, useIntl } from 'react-intl';

function ResultsHeader({ count, city }) {
  return (
    <h2>
      <FormattedMessage id="results.count" values={{ count }} />
    </h2>
  );
}

We use react-intl for this. The messages live in one file per locale. The ICU plural rules mean the component asks for "results.count with count 3" and the right grammatical form comes back in every language without an if statement in sight.

the browser already knows the formats

Numbers, dates, and units all have native formatting now and you should not be writing any of it by hand. Intl.NumberFormat knows that 1,234.5 in one country is 1.234,5 in another. Intl.DateTimeFormat knows which side of the month the day goes on. The one enterprise catch: IE11 needs the Intl polyfills for some of this, and we support IE11, so they're in the bundle :(

// distance, the local way: 12.4 km or 7.7 mi, comma or period,
// unit position decided by the locale, not by me
function formatDistance(km, locale, unit) {
  const value = unit === 'mi' ? km * 0.621371 : km;
  return new Intl.NumberFormat(locale, {
    style: 'unit',
    unit: unit === 'mi' ? 'mile' : 'kilometer',
    maximumFractionDigits: 1
  }).format(value);
}

Kilometers against miles is a product decision disguised as a formatting one, by the way. The locale gives you a default, but a dealer search in a border region taught us to let the user flip it, because the locale of your browser and the road signs outside your window do not always agree.

german will break your layout, arabic will break your assumptions

Text expansion is real and it is not subtle. German runs thirty percent longer than English routinely, Finnish compounds can double a button label, and every fixed-width button and truncated heading in the design broken you can just assume that. The layout answer is to stop designing for the English length, min-width instead of width, wrapping allowed, truncation only with a full-text affordance.

Right-to-left is the deeper one. Setting dir="rtl" on the document is the easy part. The hard part is every margin-left in the stylesheet that should have been "margin-start," every arrow icon that now points the wrong way, every progress flow that reads backwards. We are partway through converting the design system to direction-aware spacing, and new components get it from day one, which is once again more or less the entire argument for having a design system in the dust.

test in a language nobody speaks

The best trick I picked up this year is pseudo-localization. Run the app in a fake locale where every message is stretched and decorated, and three problems become visible instantly: strings that never went through the message system stay in plain English, layouts that only survive English lengths overflow, and concatenated fragments show their seams.

// dev-only pseudo locale: every string gets longer and obviously
// transformed, so hardcoded English and tight layouts expose themselves
const pseudo = (message) =>
  '[!! ' + message.replace(/[aeiou]/g, (v) => v + v) + ' !!]';

We keep it behind a dev flag and I turn it on before every review. It costs nothing and it has caught a hardcoded string in every single sprint since we added it.

the lesson under the lesson

Internationalization turned out to be the same discipline as the telemetry post and the accessibility post as well as the realization that there’s always going to be something else to learn and reconsider. It is not a feature you add, it is an assumption you remove, and it is a hundred times cheaper to remove it on day one than to excavate it later. The pattern in my career so far keeps repeating: the work that looks optional at the start of a project is the work that defines whether the project was built well. I keep a shorter list now of things I let myself assume.