Site logo
Tác giả
  • avatar Nguyễn Đức Xinh
    Name
    Nguyễn Đức Xinh
    Twitter
Ngày xuất bản
Ngày xuất bản

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

  1. Open http://localhost:5173/app — UI shows Japanese (default).
  2. Click the "English" button → UI switches to English ✅
  3. Reload the page.
  4. 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:

  • LanguageDetector is set to read from localStorage first.
  • caches: ['localStorage'] should auto-save when language changes.
  • Manual localStorage.setItem is 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 lng is set in init(), i18next uses it directly and synchronously — bypassing LanguageDetector.
  • When persistedLng is undefined (first visit, no preference), lng is 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:

  1. Relying on LanguageDetector.caches for persistence — this mechanism is not reliably triggered when changeLanguage is called manually. Fix: manually read localStorage and pass to lng option in init().

  2. Using i18n.language instead of i18n.resolvedLanguagelanguage may contain region code ('ja-JP'), breaking comparisons. Fix: always use resolvedLanguage for UI logic and conditional checks.

Two small changes, but they thoroughly solve persistence issues in multilingual apps.