How Pi Web Switches Between English and Chinese UI Using Its Custom i18n System
Pi Web uses a lightweight, client-side internationalization (i18n) layer that detects the browser's preferred language, persists user choice in localStorage, and re-renders the entire UI instantly when switching between English (en) and Simplified Chinese (zh-CN).
The agegr/pi-web repository implements a custom React-based i18n system without external dependencies like react-i18next or next-intl. This article explains exactly how the language switching mechanism works, from initial detection through persistence to UI translation.
Locale Detection and Initial Setup
Pi Web determines the starting language before any component renders. The detection chain follows a strict priority order defined in hooks/useI18n.tsx.
Saved Preference Takes Priority
The readInitialLocale() function first checks localStorage:
// hooks/useI18n.tsx
const readInitialLocale = (): Locale => {
const saved = localStorage.getItem('pi-locale');
if (saved && isValidLocale(saved)) {
return saved;
}
return resolveBrowserLocale();
};
The storage key pi-locale stores the user's explicit choice across sessions.
Browser Fallback Detection
When no saved preference exists, resolveBrowserLocale() examines navigator.languages and navigator.language:
// lib/i18n/registry.ts
export const resolveBrowserLocale = (): Locale => {
const candidates = navigator.languages || [navigator.language];
for (const lang of candidates) {
const normalized = normalizeLocale(lang);
if (supportedLocales.includes(normalized as Locale)) {
return normalized as Locale;
}
}
return 'en'; // hardcoded fallback
};
This ensures first-time visitors see their native language if supported, or English otherwise.
Document Language Synchronization
The provider immediately updates the HTML element:
// hooks/useI18n.tsx
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
This improves accessibility for screen readers and SEO indexing.
Persisting Language Selection
When users manually switch languages, Pi Web ensures the choice survives page reloads.
The setLocale State Update
The top-bar language selector in components/AppShell.tsx triggers the following flow:
// hooks/useI18n.tsx
const setLocale = useCallback((newLocale: Locale) => {
if (!getSupportedLocales().includes(newLocale)) {
console.warn(`Unsupported locale: ${newLocale}`);
return;
}
setLocaleState(newLocale);
localStorage.setItem('pi-locale', newLocale);
document.documentElement.lang = newLocale;
}, []);
The validation against registered plugins prevents invalid locale strings from corrupting the application state.
Provider Context Structure
The I18nProvider exports these values to all children:
// hooks/useI18n.tsx
return (
<I18nContext.Provider value={{
locale,
setLocale,
t,
supportedLocales: getSupportedLocales()
}}>
{children}
</I18nContext.Provider>
);
Loading and Registering Translation Messages
Pi Web uses a plugin-based registry system rather than loading JSON files at runtime.
Building the Messages Map
The provider constructs a complete message dictionary once on mount:
// hooks/useI18n.tsx
const messages = useMemo(() => {
const result: Record<Locale, MessageRecord> = {} as any;
for (const id of getSupportedLocales()) {
const plugin = getLocalePlugin(id);
result[id as Locale] = plugin.messages;
}
return result;
}, []);
getMessages() iterates over getSupportedLocales() and pulls each registered plugin's messages object via getLocalePlugin(id).
Locale Plugin Registration
Built-in languages register themselves in lib/i18n/registry.ts:
// lib/i18n/registry.ts
import { enLocale } from './messages/en';
import { zhCNLocale } from './messages/zh-CN';
registerLocale(enLocale);
registerLocale(zhCNLocale);
export const getSupportedLocales = (): Locale[] =>
Array.from(registry.keys());
export const getLocalePlugin = (id: Locale): LocalePlugin => {
const plugin = registry.get(id);
if (!plugin) throw new Error(`Locale ${id} not registered`);
return plugin;
};
Each LocalePlugin conforms to this interface:
interface LocalePlugin {
id: Locale; // 'en' | 'zh-CN'
label: string; // Display name in UI selector
messages: MessageRecord; // Key-value translation map
}
Translating UI Strings in Components
Components access translations through the useI18n hook.
Basic Hook Usage
import { useI18n } from "@/hooks/useI18n";
export function SettingsHeader() {
const { t } = useI18n();
return <h2>{t("settings.title")}</h2>;
}
The t function memoizes on [locale, messages], making locale switches instantaneous.
Fallback and Interpolation Logic
The underlying implementation in lib/i18n/format.ts handles missing keys gracefully:
export const translateMessage = (
key: string,
locale: Locale,
messages: MessageRecord,
params?: Record<string, string | number>
): string => {
const message = messages[key];
if (message === undefined) {
// Fallback to English
const fallback = getLocalePlugin('en').messages[key];
if (fallback !== undefined) {
if (process.env.NODE_ENV === 'development') {
console.warn(`Missing translation [${locale}]: ${key}`);
}
return interpolate(fallback, params);
}
// Last resort: return key
return key;
}
return interpolate(message, params);
};
This three-tier fallback (requested locale → English → raw key) ensures the UI never shows broken strings.
Parameterized Translations
// Usage with interpolation
<span>{t("session.messageCount", { count: 42 })}</span>
// lib/i18n/messages/en.ts
"session.messageCount": "{count} messages"
// lib/i18n/messages/zh-CN.ts
"session.messageCount": "{count} 条消息"
Language Selector Implementation
The actual UI for switching appears in components/AppShell.tsx:
import { useI18n } from "@/hooks/useI18n";
export function LanguageSwitcher() {
const { locale, setLocale, supportedLocales } = useI18n();
return (
<select
value={locale}
onChange={e => setLocale(e.target.value as Locale)}
className="locale-selector"
>
{supportedLocales.map(l => {
const plugin = getLocalePlugin(l);
return (
<option key={l} value={l}>
{plugin.label}
</option>
);
})}
</select>
);
}
Changing the <select> value triggers the full re-render cycle: React state updates → t function returns new strings → DOM refreshes with translated content.
Adding a New Language to Pi Web
Extending the i18n system requires three steps:
- Create the message file in
lib/i18n/messages/[locale].ts:
// lib/i18n/messages/de.ts
export const deLocale: LocalePlugin = {
id: "de",
label: "Deutsch",
messages: {
"settings.title": "Einstellungen",
"session.messageCount": "{count} Nachrichten",
// ...all required keys
},
};
- Register in the registry:
// lib/i18n/registry.ts
import { deLocale } from './messages/de';
registerLocale(deLocale);
- Update the Locale union type in
hooks/useI18n.tsx:
export type Locale = 'en' | 'zh-CN' | 'de';
The system immediately recognizes the new option without configuration changes elsewhere.
Summary
- Persistence layer:
localStoragekeypi-localesaves user preference across sessions - Detection order: saved value →
navigator.languages→navigator.language→ hardcodeden - Core hook:
useI18nfromhooks/useI18n.tsxprovideslocale,setLocale,t, andsupportedLocales - Plugin registry:
lib/i18n/registry.tsmanages registered languages viaregisterLocale - Translation lookup:
lib/i18n/format.tsimplements key resolution with English fallback and development warnings - UI trigger:
components/AppShell.tsxrenders the language selector that callssetLocale
Frequently Asked Questions
How does Pi Web remember my language choice after I close the browser?
Pi Web writes the selected locale to localStorage under the key pi-locale whenever setLocale() is called. On subsequent visits, readInitialLocale() reads this value before checking browser preferences. You can verify this by opening DevTools → Application → Local Storage → https://[your-domain].
What happens if a translation key is missing in Chinese but exists in English?
The translateMessage function in lib/i18n/format.ts implements a fallback chain: it first checks the active locale's messages, then falls back to the English plugin's messages, and finally returns the raw key if neither exists. In development mode, a console warning indicates which key is missing.
Can I add more languages without modifying core files?
You must modify lib/i18n/registry.ts to import and register new locale plugins, and update the Locale type definition in hooks/useI18n.tsx. Pi Web does not support runtime loading of external translation files—all languages are bundled at build time.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →