- Authors

- Name
- Nguyễn Đức Xinh
- Published on
- Published on
Fix i18next Not Persisting Language After Page Reload in React
Issue: Language Switch Lost After Reload
One of the most annoying bugs in multilingual apps: the user switches to English, reloads the page — the UI reverts to Japanese (or the default language). This happens every time the page is loaded.
Steps to reproduce
- Open
http://localhost:5173/app— UI shows Japanese (default). - Click the "English" button → UI switches to English ✅
- Reload the page.
- UI reverts to Japanese ❌
Initial Setup (Before Fix)
i18n.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import ja from './locales/ja.json'
import en from './locales/en.json'
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
ja: { translation: ja },
en: { translation: en },
},
supportedLngs: ['ja', 'en'],
fallbackLng: 'ja',
interpolation: { escapeValue: false },
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
lookupLocalStorage: 'i18nextLng',
},
})
export default i18n
Language Toggle Component
// layouts/AppLayout.tsx
const { i18n } = useTranslation()
const toggleLanguage = () => {
const newLang = i18n.language === 'ja' ? 'en' : 'ja'
i18n.changeLanguage(newLang)
localStorage.setItem('i18nextLng', newLang)
}
The config looks correct:
LanguageDetectoris set to read fromlocalStoragefirst.caches: ['localStorage']should auto-save when language changes.- Manual
localStorage.setItemis added for extra safety.
So why does the bug still happen?
Root Cause Analysis
Issue 1: LanguageDetector caches is not reliable when changeLanguage is called
LanguageDetector uses cacheUserLanguage — it saves the language to storages listed in caches. This mechanism is attached to the languageChanged event of i18next.
Problem: The languageChanged event and cacheUserLanguage are async. If there is any timing issue, or in certain library versions, cacheUserLanguage may not be called at the right time — resulting in the value not being saved correctly.
i18n.changeLanguage('en')
└── fires 'languageChanged' event (async)
└── LanguageDetector.cacheUserLanguage('en', ['localStorage'])
└── localStorage.setItem('i18nextLng', 'en') ← may be skipped
Even though we manually add localStorage.setItem('i18nextLng', newLang) right after, there are cases where the value is overwritten by LanguageDetector with another locale.
Issue 2: i18n.language returns full BCP 47 tag
This is a common pitfall. i18n.language does not always return 'ja' or 'en'. If the user's browser language is 'ja-JP' or 'en-US', i18n.language may return:
console.log(i18n.language) // 'ja-JP' or 'en-US'
This leads to incorrect comparison:
// ⚠️ If i18n.language is 'ja-JP'
const newLang = i18n.language === 'ja' ? 'en' : 'ja'
// 'ja-JP' === 'ja' → false!
// → newLang = 'ja' ← toggle does not work correctly
i18n.language vs i18n.resolvedLanguage
| Property | Value | Example |
|---|---|---|
i18n.language |
Detected or set language (raw) | 'ja-JP', 'en-US' |
i18n.resolvedLanguage |
Language resolved from supportedLngs |
'ja', 'en' |
Always use resolvedLanguage for comparisons and UI display.
Issue 3: lng option not set in init
When i18next initializes without the lng option, it runs LanguageDetector. LanguageDetector reads in order order: ['localStorage', 'navigator']. But due to Issue 1 (async caching), localStorage may not have the correct value → LanguageDetector falls back to navigator → browser language 'ja-JP' → i18next resolves to 'ja' (fallback) → user's choice is wiped out.
How to Fix
Fix 1: Read localStorage directly and set lng in init
Instead of relying entirely on LanguageDetector to read the saved language, we manually read localStorage and pass it directly to the lng option:
// i18n.ts — AFTER FIX
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import ja from './locales/ja.json'
import en from './locales/en.json'
const SUPPORTED_LANGS = ['ja', 'en'] as const
const LANG_STORAGE_KEY = 'i18nextLng'
// ✅ Explicitly read saved language
const savedLang = localStorage.getItem(LANG_STORAGE_KEY)
const persistedLng = SUPPORTED_LANGS.includes(savedLang as (typeof SUPPORTED_LANGS)[number])
? (savedLang as string)
: undefined
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
lng: persistedLng, // ✅ Set saved language here
resources: {
ja: { translation: ja },
en: { translation: en },
},
supportedLngs: SUPPORTED_LANGS,
fallbackLng: 'ja',
interpolation: { escapeValue: false },
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
lookupLocalStorage: LANG_STORAGE_KEY,
},
})
export default i18n
Why does this work?
- When
lngis set ininit(), i18next uses it directly and synchronously — bypassing LanguageDetector. - When
persistedLngisundefined(first visit, no preference),lngis not set → LanguageDetector runs → detects from browser navigator → works as usual.
First visit (no localStorage):
lng = undefined → LanguageDetector → navigator → 'ja' → fallbackLng → 'ja' ✅
Next visit (saved 'en'):
lng = 'en' → i18next init with 'en' immediately ✅
LanguageDetector is bypassed (lng is set)
Fix 2: Use resolvedLanguage instead of language
// layouts/AppLayout.tsx — AFTER FIX
const toggleLanguage = () => {
// ✅ resolvedLanguage is always 'ja' or 'en' (matches supportedLngs)
const newLang = i18n.resolvedLanguage === 'ja' ? 'en' : 'ja'
void i18n.changeLanguage(newLang)
localStorage.setItem('i18nextLng', newLang)
}
// In JSX:
{i18n.resolvedLanguage === 'ja' ? 'English' : '日本語'}
Workflow After Fix
First time:
1. localStorage does not have 'i18nextLng'
2. persistedLng = undefined → lng not set
3. LanguageDetector → detects from navigator → 'ja'
4. i18n.resolvedLanguage = 'ja'
5. Button shows: "English"
User clicks "English":
1. newLang = 'en' (because resolvedLanguage === 'ja')
2. i18n.changeLanguage('en')
3. localStorage.setItem('i18nextLng', 'en')
4. i18n.resolvedLanguage = 'en'
5. Button shows: "日本語"
Reload page:
1. localStorage has 'i18nextLng' = 'en'
2. persistedLng = 'en' → lng = 'en'
3. i18n init with 'en' immediately ✅
4. Button shows: "日本語" ✅
Common i18next Pitfalls
Using i18n.language instead of resolvedLanguage for comparison
// ❌ May return 'ja-JP', 'en-US', ...
{i18n.language === 'ja' ? 'English' : '日本語'}
// ✅ Always returns value in supportedLngs
{i18n.resolvedLanguage === 'ja' ? 'English' : '日本語'}
Relying entirely on LanguageDetector for persistence
// ❌ Not 100% reliable in all versions
detection: {
caches: ['localStorage'],
}
// ✅ Manually read and pass to lng option
const savedLang = localStorage.getItem('i18nextLng')
i18n.init({ lng: savedLang || undefined, ... })
Forgetting to await changeLanguage
// ⚠️ changeLanguage returns a Promise
i18n.changeLanguage(newLang) // no await, no error handling
// ✅ Better — use void or await
void i18n.changeLanguage(newLang)
// or
await i18n.changeLanguage(newLang)
Hardcoding key in multiple places
// ❌ Prone to typos, hard to maintain
i18n.changeLanguage(newLang)
localStorage.setItem('i18nextLng', newLang) // key hardcoded
localStorage.getItem('i18nextLng') // key hardcoded elsewhere
// ✅ Define constant for reuse
const LANG_STORAGE_KEY = 'i18nextLng'
const SUPPORTED_LANGS = ['ja', 'en'] as const
Conclusion
i18next not persisting language is usually caused by two main reasons:
-
Relying on
LanguageDetector.cachesfor persistence — this mechanism is not reliably triggered whenchangeLanguageis called manually. Fix: manually readlocalStorageand pass tolngoption ininit(). -
Using
i18n.languageinstead ofi18n.resolvedLanguage—languagemay contain region code ('ja-JP'), breaking comparisons. Fix: always useresolvedLanguagefor UI logic and conditional checks.
Two small changes, but they thoroughly solve persistence issues in multilingual apps.
