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 Lỗi i18next Không Lưu Ngôn Ngữ Sau Khi Reload Trang Trong React

Vấn Đề: Chọn Ngôn Ngữ Xong Reload Là Mất

Một trong những lỗi gây khó chịu nhất trong ứng dụng đa ngôn ngữ: người dùng chuyển sang English, reload trang — giao diện lại quay về tiếng Nhật (hoặc ngôn ngữ mặc định). Lặp đi lặp lại mỗi lần vào trang.

Steps to reproduce

  1. Mở http://localhost:5173/app — giao diện hiển thị tiếng Nhật (mặc định).
  2. Click nút "English" → giao diện chuyển sang tiếng Anh ✅
  3. Reload trang.
  4. Giao diện quay về tiếng Nhật

Thiết Lập Ban Đầu (Trước Khi 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)
}

Nhìn vào config thì có vẻ đúng:

  • LanguageDetector được cấu hình đọc từ localStorage trước.
  • caches: ['localStorage'] nên tự lưu khi thay đổi.
  • Code còn thủ công localStorage.setItem để chắc ăn.

Vậy tại sao vẫn lỗi?


Phân Tích Nguyên Nhân

Vấn đề 1: LanguageDetector caches không đảm bảo hoạt động khi changeLanguage được gọi

LanguageDetector có cơ chế cacheUserLanguage — lưu ngôn ngữ vào các storage được liệt kê trong caches. Cơ chế này được gắn vào sự kiện languageChanged của i18next.

Vấn đề: Sự kiện languageChangedcacheUserLanguageasync. Nếu có bất kỳ timing issue nào, hoặc trong một số phiên bản nhất định của thư viện, cacheUserLanguage có thể không được gọi đúng lúc — dẫn đến giá trị không được lưu đúng.

i18n.changeLanguage('en')
  └── fires 'languageChanged' event (async)
        └── LanguageDetector.cacheUserLanguage('en', ['localStorage'])
              └── localStorage.setItem('i18nextLng', 'en')  ← có thể bị bỏ qua

Dù chúng ta đã thêm localStorage.setItem('i18nextLng', newLang) thủ công ngay sau đó, vẫn có trường hợp giá trị bị overwrite bởi LanguageDetector với một locale khác.

Vấn đề 2: i18n.language trả về BCP 47 tag đầy đủ

Đây là một cạm bẫy phổ biến. i18n.language không phải lúc nào cũng trả về 'ja' hay 'en'. Nếu browser language của người dùng là 'ja-JP' hoặc 'en-US', i18n.language có thể trả về:

console.log(i18n.language) // 'ja-JP' hoặc 'en-US'

Dẫn đến comparison bị sai:

// ⚠️ Nếu i18n.language là 'ja-JP'
const newLang = i18n.language === 'ja' ? 'en' : 'ja'
//              'ja-JP' === 'ja'  → false!
//              → newLang = 'ja'  ← toggle không hoạt động đúng

i18n.language vs i18n.resolvedLanguage

Property Giá trị Ví dụ
i18n.language Ngôn ngữ được detect hoặc set (raw) 'ja-JP', 'en-US'
i18n.resolvedLanguage Ngôn ngữ được resolve từ supportedLngs 'ja', 'en'

Luôn dùng resolvedLanguage cho các phép so sánh và hiển thị.

Vấn đề 3: lng option không được set trong init

Khi i18next khởi tạo mà không có lng option, nó chạy LanguageDetector. LanguageDetector đọc theo thứ tự order: ['localStorage', 'navigator']. Nhưng do vấn đề 1 (async caching), localStorage có thể không có giá trị đúng → LanguageDetector fallback về navigator → browser language 'ja-JP' → i18next resolve về 'ja' (fallback) → xóa hết lựa chọn của user.


Cách Fix

Fix 1: Đọc localStorage trực tiếp và set lng trong init

Thay vì phụ thuộc hoàn toàn vào LanguageDetector để đọc ngôn ngữ đã lưu, chúng ta tự đọc localStorage và truyền thẳng vào option lng:

// 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'

// ✅ Đọc ngôn ngữ đã lưu một cách tường minh
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 ngôn ngữ đã lưu vào đây
    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

Tại sao cách này hoạt động?

  • Khi lng được set trong init(), i18next dùng nó trực tiếp và đồng bộ — không qua LanguageDetector.
  • Khi persistedLngundefined (lần đầu vào, chưa có preference), lng không được set → LanguageDetector chạy → detect từ browser navigator → hoạt động như bình thường.
Lần đầu vào (không có localStorage):
  lng = undefined → LanguageDetector → navigator → 'ja' → fallbackLng → 'ja' ✅

Lần tiếp theo (đã lưu 'en'):
  lng = 'en' → i18next init với 'en' ngay lập tức ✅
  LanguageDetector bị bypass (lng đã được set)

Fix 2: Dùng resolvedLanguage thay vì language

// layouts/AppLayout.tsx — AFTER FIX
const toggleLanguage = () => {
  // ✅ resolvedLanguage luôn là 'ja' hoặc 'en' (đúng với supportedLngs)
  const newLang = i18n.resolvedLanguage === 'ja' ? 'en' : 'ja'
  void i18n.changeLanguage(newLang)
  localStorage.setItem('i18nextLng', newLang)
}

// Trong JSX:
{i18n.resolvedLanguage === 'ja' ? 'English' : '日本語'}

Luồng Hoạt Động Sau Fix

Lần đầu tiên:
  1. localStorage không có 'i18nextLng'
  2. persistedLng = undefined → lng không set
  3. LanguageDetector → detect từ navigator → 'ja'
  4. i18n.resolvedLanguage = 'ja'
  5. Button hiển thị: "English"

Người dùng click "English":
  1. newLang = 'en' (vì resolvedLanguage === 'ja')
  2. i18n.changeLanguage('en')
  3. localStorage.setItem('i18nextLng', 'en')
  4. i18n.resolvedLanguage = 'en'
  5. Button hiển thị: "日本語"

Reload trang:
  1. localStorage có 'i18nextLng' = 'en'
  2. persistedLng = 'en' → lng = 'en'
  3. i18n init với 'en' ngay lập tức ✅
  4. Button hiển thị: "日本語" ✅

Tổng Hợp Các Lỗi Thường Gặp Với i18next

Sử dụng i18n.language thay vì resolvedLanguage để so sánh

// ❌ Có thể trả về 'ja-JP', 'en-US', ...
{i18n.language === 'ja' ? 'English' : '日本語'}

// ✅ Luôn trả về giá trị trong supportedLngs
{i18n.resolvedLanguage === 'ja' ? 'English' : '日本語'}

Phụ thuộc hoàn toàn vào LanguageDetector để persist

// ❌ Không đáng tin cậy 100% trong mọi phiên bản
detection: {
  caches: ['localStorage'],
}

// ✅ Tự đọc và truyền vào lng option
const savedLang = localStorage.getItem('i18nextLng')
i18n.init({ lng: savedLang || undefined, ... })

Quên await changeLanguage

// ⚠️ changeLanguage trả về Promise
i18n.changeLanguage(newLang) // không await, không xử lý lỗi

// ✅ Tốt hơn — sử dụng void hoặc await
void i18n.changeLanguage(newLang)
// hoặc
await i18n.changeLanguage(newLang)

Hardcode key trong nhiều chỗ

// ❌ Dễ typo, khó maintain
i18n.changeLanguage(newLang)
localStorage.setItem('i18nextLng', newLang)  // key hardcode
localStorage.getItem('i18nextLng')           // key hardcode ở chỗ khác

// ✅ Định nghĩa constant dùng chung
const LANG_STORAGE_KEY = 'i18nextLng'
const SUPPORTED_LANGS = ['ja', 'en'] as const

Kết Luận

Lỗi i18next không lưu ngôn ngữ thường xuất phát từ hai nguyên nhân chính:

  1. Phụ thuộc vào LanguageDetector.caches để persist — cơ chế này không đảm bảo hoạt động ổn định khi changeLanguage được gọi thủ công. Fix: tự đọc localStorage và truyền vào lng option trong init().

  2. Dùng i18n.language thay vì i18n.resolvedLanguagelanguage có thể chứa region code ('ja-JP'), làm hỏng các phép so sánh. Fix: luôn dùng resolvedLanguage cho UI logic và conditional checks.

Hai thay đổi nhỏ, nhưng giúp xử lý triệt để vấn đề persistence trong ứng dụng đa ngôn ngữ.