Meet Modi
Back to Blog
·5 min·updated

The translation arrived and the screen didn't notice

A component mounted before its locale chunk landed stayed frozen on the English fallback, even though t() resolved the translation. bindI18nStore is why.

By Meet Modi
InternationalizationReactFrontendDebuggingTesting

react-i18next does not re-render when a lazily loaded namespace is added to the store, because useTranslation's default bindI18nStore is an empty string, meaning no store events are bound. A component that mounted before its chunk arrived keeps showing the fallback even though t() would now return the translation. Set bindI18nStore to "added removed" on the instance.

I found this in a browser, not in a test, and the test suite had been green the whole time. That part turns out to be the interesting bit.

The architecture that guarantees the race

The app bundles English statically, so English costs zero extra requests and always works as a fallback. Every other language is a lazy chunk fetched on demand. On top of that, it is a micro-frontend, and each remote registers its own translation namespaces when it mounts.

So resources arrive after mount by design. Not occasionally, not under load. By design, on every single language that is not English, on every remote.

The symptom

Switch to Hindi. Most of the screen turns Hindi. A few components stay in English permanently, with no pattern anyone could name, and no amount of navigating back and forth fixes them. Remount the component and it comes back correct, which is what sent me looking at timing.

What was actually happening

useTranslation subscribes to i18next events so it can re-render when translations change. Which events it subscribes to is configurable, and there are two separate knobs.

  • bindI18n defaults to "languageChanged", so a language switch re-renders. This is the one everyone knows about and it works.
  • bindI18nStore defaults to an empty string, which binds nothing. Resources being added to the store do not re-render anything.

That default is reasonable for the common setup, where all resources are present before the first render and the store never changes afterwards. Binding store events there would be pure overhead. It is exactly wrong for lazy loading, where the store filling up after mount is the normal path.

So the sequence was: component mounts, namespace absent, t() falls back to English, component renders English. Chunk lands, store gains the namespace, no event is bound, nothing re-renders. The component now holds a t() that would return Hindi and will never be asked again.

i18n.init({
  fallbackLng: "en",
  react: {
    // Default: re-render when the language changes.
    bindI18n: "languageChanged",
    // Default is "", meaning nothing. Resources arrive after mount by design,
    // so the store's own events have to re-render too.
    bindI18nStore: "added removed",
  },
});

Why the tests were green

Every existing test initialised i18next with all resources inline and then rendered. The store was complete before the first render, so the missing binding could not possibly matter. The suite was testing a world the app never runs in.

The regression test had to invert that order, which is three lines and the only honest way to pin this:

it("re-renders a mounted component when a late namespace lands", async () => {
  // Mount with the namespace deliberately absent.
  render(<Greeting />, { wrapper: I18nWrapper });
  expect(screen.getByText("Hello")).toBeInTheDocument();   // fallback

  // The lazy chunk arrives after mount, as it does in production.
  act(() => { i18n.addResourceBundle("hi", "greeting", { hello: "नमस्ते" }); });

  await screen.findByText("नमस्ते");
});

The second ordering bug underneath

The same investigation turned up a related one in the native path. The host app pushes the language over a JS bridge at boot, and webviews are pre-warmed, so the push could arrive before the adapter that listens for it had attached. Same shape, different transport: a message delivered to nobody is indistinguishable from a message never sent.

The fix there is to queue and replay on attach, which is the same move I had to make for a different dropped bridge message in the message that opened the screen.

The general lesson is cheap to state and easy to forget. Any library whose job is "re-render when data changes" has a list of what counts as a change, that list has a default, and the default was chosen for the common case. If your architecture is not the common case, read the list.

Falling back to English is sometimes the bug rather than the safety net. A translate button that quietly did nothing failed because detection fell through to 'en' and the translator saw its source matching its target.

More Posts