La Élume Beauté · Chapter 2 of 7

Switching language mid-request

Jovanus Hartono2026

On this page

The store speaks three languages: English, Indonesian and Chinese. You can switch from the header at any moment, even halfway through loading a page, and the switch has to reach two different places, because the words on the page come from two different sources.

Two sources of words

Product names, descriptions and the rest of the catalog come from the backend. Every request the store makes carries your language in the Accept-Language A short label sent along with a request, like the language you want the answer in., as in Accept-Language: id, so the backend can answer in it. The header is added by the same fetch function that adds your token, so no screen has to remember to send it.

The words around the catalog, like the menu, the search box and the cart button, live in the store itself, in one small dictionary per language:

const en = {
  header: { search: "Search", cart: "Cart" },
  account: { signIn: "Sign in" },
};

const id: Dictionary = {
  header: { search: "Cari", cart: "Keranjang" },
  account: { signIn: "Masuk" },
};

t("header.search"); // "Search", or "Cari" in Indonesian
t("header.serach"); // doesn't compile: there's no such key

A few choices here do most of the work:

  • A key is a dotted path like header.search, and TypeScript knows every valid path. A small recursive type built with template literal types turns the English dictionary into a list of every path in it, so a mistyped key doesn't compile, and autocomplete lists them all.
  • The Indonesian and Chinese dictionaries are typed against the English one (that's the Dictionary above), so a key I add in English and forget to translate doesn't compile either.
  • If a key still goes missing at runtime, the screen shows the raw path, header.search, instead of crashing, which makes a broken label easy to spot.

The first language comes from your browser's navigator.language. If it's one of the three, the store starts in it, and otherwise in English:

const browser = navigator.language.split("-")[0]; // "id-ID" becomes "id"
const start = ["en", "id", "zh"].includes(browser) ? browser : "en";

After that, your choice is saved in localStorage and checked again as it loads, so a leftover value can't put the store in a language it doesn't have. A switch also updates the page's lang attribute, so screen readers know which language they're reading.

Switching mid-request

TanStack Query stores every answer under a The label a cached answer is filed under. Two requests with the same key share one cached answer., and the store's query keys don't include the language. So the product list in English and the product list in Indonesian share one cache entry. A switch handles that by invalidating every query: each one is marked Out of date. A stale answer still shows, but it gets fetched again as soon as it can., and whatever is on screen fetches again in the new language.

That left a gap, one that never reached a shopper. Say the product list is still loading in English when you switch to Indonesian. Marking it stale doesn't start a new request, because TanStack Query sees one already on its way and waits for it. The English answer lands, counts as fresh, and you're reading English in a store that says it's in Indonesian.

invalidateQueries()switch to Indonesianproducts in Englishno new request, it waits for this oneThe English answer lands and is saved as fresh.cancelQueries(), then invalidateQueries()switch to IndonesianEnglish, cancelledin IndonesianOnly the Indonesian answer reaches the cache.
Query keys don't include the language. Marking a query stale while its first request is still on the way doesn't start a new one, so the English answer is saved as fresh. Cancelling first forces a new request in the new language.

The fix was a single line before the invalidation that cancels every request still on its way, so the invalidation always starts a new request in the new language:

languageStore.subscribe((state, previous) => {
  if (state.language === previous.language) {
    return;
  }
  document.documentElement.lang = state.language;

  queryClient.cancelQueries(); // drop the answers still coming in the old language
  queryClient.invalidateQueries(); // then fetch everything on screen again
});

The key called toString

The same change also made the dictionary lookup safer. It walked a dotted path one segment at a time, checking with in whether each segment existed. But in also finds properties an object inherits, so a segment like toString or constructor "exists" in every dictionary, and the lookup would wander off into JavaScript's built-in The object every plain JavaScript object inherits from, where built-in methods like toString live.:

const dictionary = { header: { search: "Search" } };

"toString" in dictionary; // true, inherited from Object.prototype
Object.hasOwn(dictionary, "toString"); // false

It now checks with Object.hasOwn, which only counts the dictionary's own keys. The typed keys already made that nearly impossible to hit, since a path like that wouldn't compile.

From a button to a menu

On desktop, the language started as a short label in the header, EN, ID or 中, that moved to the next language each time you clicked it. With three languages, going from Indonesian back to English meant clicking through Chinese first. It shipped as that button, and the button gave way to a small menu listing all three by name, English, Bahasa and 普通话, with the one you're on in bold.

The right side of the store's header with the language menu open, listing English, Bahasa and 普通话 with their short codes EN, ID and 中. English is in bold.
The language menu on desktop, rendered locally with a made-up account.