Choosing a language for three cards shouldn't change the whole app
A language picker inside one feed wrote to the app-wide i18next instance and persisted it. A context scope ends when the subtree does.
By Meet Modii18next.changeLanguage is global and usually persisted, so calling it from a picker inside one screen changes the language of the entire app and keeps it changed after the user leaves. To scope a language to a subtree, put the chosen language in a React context and have your translation hook prefer the scope when it is inside one, leaving the global instance untouched.
The report
A learning feed has a globe icon that switches the language of the cards you are reading. Someone used it to read a few cards in Hindi, backed out to the home screen, and found the whole app in Hindi. Closed it, reopened it the next day: still Hindi.
Not a crash, not a visual glitch. The feature did exactly what it said and then kept doing it forever.
Why it leaked
The picker called through the app's language manager, which is a deliberate window-level singleton. It resolves a code, checks a rollout gate, persists the choice, applies dir to the html element and notifies every subscriber. All correct, and all of it app-wide by design, because that is what the manager is for: the language the whole app runs in.
The picker in the feed wants something different that happens to look identical at the call site. It wants "render this subtree in Hindi for as long as the subtree exists". There was no API for that, so it used the one that was there.
The fix is a context boundary
A React context has exactly the lifetime we want for free. It begins when the provider mounts and ends when the subtree unmounts. Nothing to clean up on exit, nothing to restore, no way to leave the app stranded because nothing outside the subtree ever read it.
const UiLanguageScope = createContext<string | null>(null);
export function UiLanguageScopeProvider({ lang, children }) {
return <UiLanguageScope.Provider value={lang}>{children}</UiLanguageScope.Provider>;
}
// The shared hook every surface already used. Unchanged for every caller
// that is not inside a scope, which is every other surface in the app.
export function useLanguageTranslation(ns?: string) {
const scoped = useContext(UiLanguageScope);
const { t, i18n } = useTranslation(ns);
if (!scoped) return { t, i18n };
return { t: i18n.getFixedT(scoped, ns ?? null), i18n };
}getFixedT is the part that does the work. It returns a translate function pinned to a language without touching the instance's current language, which is precisely the distinction the original code could not express.
Make the scope opt-out, not opt-in
The change that made this safe to ship was putting the scope inside the hook every surface already called, rather than introducing a second hook for scoped surfaces. Call sites outside a scope see no behaviour change at all, because the context is null and the hook returns what it always returned. Call sites inside one get the scoped language without knowing they are scoped.
The alternative, a useScopedTranslation that components inside the feed must remember to use, fails the first time someone adds a component and reaches for the hook they know. Then you have a card where two labels disagree about what language it is in.
What still has to be global
Not everything can live in the scope. The component library takes direction from its own provider rather than from the DOM, so a scope that changes language also has to restate direction for it, which I ran into separately in the component library that outranked my dir attribute. And lazily loaded locale chunks are shared, so a scope has to wait on the same resource load as everyone else.
The rule I would write down: a setting that a screen owns should be stored where the screen is stored. If the only API you have writes to the app, you do not have the API you need yet.
More Posts
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.