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:

  1. 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
  },
};
  1. Register in the registry:
// lib/i18n/registry.ts
import { deLocale } from './messages/de';
registerLocale(deLocale);
  1. 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: localStorage key pi-locale saves user preference across sessions
  • Detection order: saved value → navigator.languages → navigator.language → hardcoded en
  • Core hook: useI18n from hooks/useI18n.tsx provides locale, setLocale, t, and supportedLocales
  • Plugin registry: lib/i18n/registry.ts manages registered languages via registerLocale
  • Translation lookup: lib/i18n/format.ts implements key resolution with English fallback and development warnings
  • UI trigger: components/AppShell.tsx renders the language selector that calls setLocale

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →