# How Pi Web Switches Between English and Chinese UI Using Its Custom i18n System

> Discover how Pi Web's custom i18n system switches between English and Chinese UI instantly. Learn about its client-side approach and persistent user choice for a seamless experience.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: internals
- Published: 2026-08-14

---

**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`](https://github.com/agegr/pi-web/blob/main/hooks/useI18n.tsx).

### Saved Preference Takes Priority

The `readInitialLocale()` function first checks `localStorage`:

```typescript
// 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`:

```typescript
// 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:

```typescript
// 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`](https://github.com/agegr/pi-web/blob/main/components/AppShell.tsx) triggers the following flow:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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`](https://github.com/agegr/pi-web/blob/main/lib/i18n/registry.ts):

```typescript
// 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:

```typescript
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

```tsx
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`](https://github.com/agegr/pi-web/blob/main/lib/i18n/format.ts) handles missing keys gracefully:

```typescript
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

```tsx
// 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`](https://github.com/agegr/pi-web/blob/main/components/AppShell.tsx):

```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`:

```typescript
// lib/i18n/messages/de.ts
export const deLocale: LocalePlugin = {
  id: "de",
  label: "Deutsch",
  messages: {
    "settings.title": "Einstellungen",
    "session.messageCount": "{count} Nachrichten",
    // ...all required keys
  },
};

```

2. **Register in the registry**:

```typescript
// lib/i18n/registry.ts
import { deLocale } from './messages/de';
registerLocale(deLocale);

```

3. **Update the Locale union type** in [`hooks/useI18n.tsx`](https://github.com/agegr/pi-web/blob/main/hooks/useI18n.tsx):

```typescript
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`](https://github.com/agegr/pi-web/blob/main/hooks/useI18n.tsx) provides `locale`, `setLocale`, `t`, and `supportedLocales`
- **Plugin registry**: [`lib/i18n/registry.ts`](https://github.com/agegr/pi-web/blob/main/lib/i18n/registry.ts) manages registered languages via `registerLocale`
- **Translation lookup**: [`lib/i18n/format.ts`](https://github.com/agegr/pi-web/blob/main/lib/i18n/format.ts) implements key resolution with English fallback and development warnings
- **UI trigger**: [`components/AppShell.tsx`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/lib/i18n/registry.ts) to import and register new locale plugins, and update the `Locale` type definition in [`hooks/useI18n.tsx`](https://github.com/agegr/pi-web/blob/main/hooks/useI18n.tsx). Pi Web does not support runtime loading of external translation files—all languages are bundled at build time.