Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React

React-i18next - Internacjonalizacja aplikacji React

14 min czytania
W tej lekcji11

Na pokład wchodzi załoga z całej galaktyki: kapitan czyta komunikaty po polsku, nawigatorka po angielsku, a inżynier po hiszpańsku. Jeśli teksty są wpisane na sztywno w JSX, każdy nowy język oznacza przepisywanie komponentów. Rozwiązaniem jest internacjonalizacja (i18n): komponent zna tylko klucze, a tłumaczenia leżą w osobnych plikach. React-i18next łączy z Reactem bibliotekę i18next, która formatuje też daty, liczby i waluty.

Instalacja i konfiguracja

Instalujesz rdzeń i18next, integrację react-i18next i dwie opcjonalne wtyczki: wykrywacz języka oraz backend HTTP:

1npm install react-i18next i18next i18next-browser-languagedetector i18next-http-backend

Konfiguracja to łańcuch: każde use() podłącza wtyczkę, init() ustawia opcje, a na końcu eksportujesz instancję:

1// i18n.js
2import i18n from 'i18next';
3import { initReactI18next } from 'react-i18next';
4import Backend from 'i18next-http-backend';
5import LanguageDetector from 'i18next-browser-languagedetector';
6
7i18n
8  .use(Backend) // Ładowanie tłumaczeń z serwera
9  .use(LanguageDetector) // Automatyczne wykrywanie języka
10  .use(initReactI18next) // Integracja z React
11  .init({
12    // Konfiguracja fallback
13    fallbackLng: 'en',
14
15    // Debugowanie w development
16    debug: process.env.NODE_ENV === 'development',
17
18    // Interpolacja
19    interpolation: {
20      escapeValue: false // React już zabezpiecza przed XSS
21    },
22
23    // Konfiguracja Backend
24    backend: {
25      loadPath: '/locales/{{lng}}/{{ns}}.json'
26    },
27
28    // Konfiguracja detectora języka
29    detection: {
30      order: ['localStorage', 'navigator', 'htmlTag'],
31      caches: ['localStorage']
32    }
33  });
34
35export default i18n;

fallbackLng: 'en' to język awaryjny, a escapeValue: false wyłącza podwójne escapowanie, bo React sam chroni przed XSS. W prostszym projekcie zamiast backendu podasz tłumaczenia wprost: init({ resources, lng }).

Konfigurację importujesz raz, w pliku startowym, przed renderowaniem aplikacji:

1// index.js
2import React from 'react';
3import ReactDOM from 'react-dom/client';
4import './i18n'; // Import konfiguracji
5import App from './App';
6
7const root = ReactDOM.createRoot(document.getElementById('root'));
8root.render(<App />);

Import tylko inicjalizuje i18next, więc App nie dostaje niczego przez propsy.

Hook useTranslation

W komponencie sięgasz po useTranslation. Hook zwraca funkcję t(), która zamienia klucz na tekst, i obiekt i18n z metodą changeLanguage:

1// App.jsx
2import { useTranslation } from 'react-i18next';
3
4function App() {
5  const { t, i18n } = useTranslation();
6
7  const changeLanguage = (lang) => {
8    i18n.changeLanguage(lang);
9  };
10
11  return (
12    <div>
13      <h1>{t('welcome.title')}</h1>
14      <p>{t('welcome.description')}</p>
15
16      <div>
17        <button onClick={() => changeLanguage('en')}>English</button>
18        <button onClick={() => changeLanguage('pl')}>Polski</button>
19        <button onClick={() => changeLanguage('es')}>Español</button>
20      </div>
21
22      <p>{t('current_language')}: {i18n.language}</p>
23    </div>
24  );
25}
26
27export default App;

Klucz welcome.title wskazuje zagnieżdżone pole w JSON. Po changeLanguage('pl') każdy komponent z tym hookiem przerenderuje się z nowymi tekstami.

Pliki tłumaczeń mają identyczne klucze i różnią się tylko wartościami:

1// public/locales/en/translation.json
2{
3  "welcome": {
4    "title": "Welcome to our application",
5    "description": "This is a multilingual React application"
6  },
7  "current_language": "Current language",
8  "navigation": {
9    "home": "Home",
10    "about": "About",
11    "contact": "Contact"
12  }
13}
14
15// public/locales/pl/translation.json
16{
17  "welcome": {
18    "title": "Witaj w naszej aplikacji",
19    "description": "To jest wielojęzyczna aplikacja React"
20  },
21  "current_language": "Aktualny język",
22  "navigation": {
23    "home": "Strona główna",
24    "about": "O nas",
25    "contact": "Kontakt"
26  }
27}

Backend znajdzie je pod ścieżką /locales/{{lng}}/{{ns}}.json, gdzie lng to język, a ns przestrzeń nazw.

Interpolacja i pluralizacja

Dane, jak imię czy liczbę wiadomości, przekazujesz jako drugi argument funkcji t():

1function UserProfile({ user, messageCount }) {
2  const { t } = useTranslation();
3
4  return (
5    <div>
6      {/* Interpolacja */}
7      <h1>{t('user.greeting', { name: user.name })}</h1>
8
9      {/* Pluralizacja */}
10      <p>{t('messages.count', { count: messageCount })}</p>
11
12      {/* Formatowanie dat */}
13      <p>{t('user.joined', {
14        date: new Date(user.joinedAt),
15        formatParams: {
16          date: { year: 'numeric', month: 'long', day: 'numeric' }
17        }
18      })}</p>
19    </div>
20  );
21}

{{name}} w tłumaczeniu zostanie zastąpione wartością user.name. Przy liczbie mnogiej zmienna musi nazywać się dokładnie count. Datę formatuje zapis {{date, datetime}} w tłumaczeniu, a formatParams przekazuje mu opcje Intl.DateTimeFormat.

Formy liczby mnogiej rozróżniają przyrostki kluczy:

1// Pliki tłumaczeń z pluralizacją
2{
3  "user": {
4    "greeting": "Hello, {{name}}!",
5    "joined": "Member since {{date, datetime}}"
6  },
7  "messages": {
8    "count_zero": "No messages",
9    "count_one": "1 message",
10    "count_other": "{{count}} messages"
11  }
12}

Od wersji 21 i18next dobiera przyrostek przez Intl.PluralRules: angielski ma formy _one i _other, a _zero to dodatkowy wariant dla zera. Polski potrzebuje _one (1 wiadomość), _few (2 wiadomości), _many (5 wiadomości) i _other (ułamki). Komponent się nie zmienia, zmieniają się tylko pliki.

Przestrzenie nazw

Namespaces dzielą ogromny plik tłumaczeń na czytelne moduły, np. navigation, forms i errors:

1// Konfiguracja z namespace
2i18n.init({
3  // Przestrzeń domyślna i lista wszystkich przestrzeni
4  defaultNS: 'common',
5  ns: ['common', 'navigation', 'forms', 'errors']
6});
7
8// Użycie różnych namespace
9function Navigation() {
10  const { t } = useTranslation('navigation');
11
12  return (
13    <nav>
14      <a href="/">{t('home')}</a>
15      <a href="/about">{t('about')}</a>
16      <a href="/contact">{t('contact')}</a>
17    </nav>
18  );
19}
20
21function ContactForm() {
22  const { t } = useTranslation(['forms', 'errors']);
23
24  return (
25    <form>
26      <label>{t('forms:name.label')}</label>
27      <input
28        required
29      />
30
31      <label>{t('forms:email.label')}</label>
32      <input
33        type="email"
34        required
35      />
36
37      <button type="submit">
38        {t('forms:submit')}
39      </button>
40    </form>
41  );
42}

useTranslation('navigation') wybiera przestrzeń dla komponentu, a zapis forms:name.label sięga po klucz z konkretnej przestrzeni.

Kontekst języka

Przełącznik języka potrzebuje zwykle zapisu wyboru, listy języków i kierunku tekstu. Zbiera to LanguageProvider:

1// LanguageContext.jsx
2import { createContext, useContext, useState } from 'react';
3import { useTranslation } from 'react-i18next';
4
5const LanguageContext = createContext();
6
7export function LanguageProvider({ children }) {
8  const { i18n } = useTranslation();
9  const [language, setLanguage] = useState(i18n.language);
10
11  const changeLanguage = async (newLang) => {
12    await i18n.changeLanguage(newLang);
13    setLanguage(newLang);
14
15    // Zapisz wybór w localStorage
16    localStorage.setItem('preferredLanguage', newLang);
17
18    // Zmień kierunek tekstu dla języków RTL
19    if (['ar', 'he', 'fa'].includes(newLang)) {
20      document.dir = 'rtl';
21    } else {
22      document.dir = 'ltr';
23    }
24  };
25
26  const availableLanguages = [
27    { code: 'en', name: 'English' },
28    { code: 'pl', name: 'Polski' },
29    { code: 'es', name: 'Español' },
30    { code: 'fr', name: 'Français' },
31    { code: 'de', name: 'Deutsch' }
32  ];
33
34  return (
35    <LanguageContext.Provider value={{
36      language,
37      changeLanguage,
38      availableLanguages,
39      isRTL: ['ar', 'he', 'fa'].includes(language)
40    }}>
41      {children}
42    </LanguageContext.Provider>
43  );
44}
45
46export const useLanguage = () => {
47  const context = useContext(LanguageContext);
48  if (!context) {
49    throw new Error('useLanguage must be used within LanguageProvider');
50  }
51  return context;
52};
53
54// LanguageSelector.jsx
55function LanguageSelector() {
56  const { language, changeLanguage, availableLanguages } = useLanguage();
57  const { t } = useTranslation();
58
59  return (
60    <div className="language-selector">
61      <select
62        value={language}
63        onChange={(e) => changeLanguage(e.target.value)}
64        aria-label={t('common:select_language')}
65      >
66        {availableLanguages.map(lang => (
67          <option key={lang.code} value={lang.code}>
68            {lang.name}
69          </option>
70        ))}
71      </select>
72    </div>
73  );
74}

Dla arabskiego, hebrajskiego i perskiego provider ustawia document.dir = 'rtl'. LanguageSelector to zwykły select sterowany z kontekstu.

Daty, liczby i waluty

Daty każdy kraj zapisuje inaczej, więc zamiast ręcznego formatowania użyj Intl.DateTimeFormat z językiem z i18n.language:

1// DateFormatter.jsx
2import { useTranslation } from 'react-i18next';
3
4function DateFormatter({ date, format = 'short' }) {
5  const { i18n } = useTranslation();
6
7  const formatDate = (date, format) => {
8    const formatOptions = {
9      short: {
10        year: 'numeric',
11        month: 'short',
12        day: 'numeric'
13      },
14      long: {
15        year: 'numeric',
16        month: 'long',
17        day: 'numeric',
18        weekday: 'long'
19      },
20      time: {
21        hour: '2-digit',
22        minute: '2-digit'
23      },
24      datetime: {
25        year: 'numeric',
26        month: 'short',
27        day: 'numeric',
28        hour: '2-digit',
29        minute: '2-digit'
30      }
31    };
32
33    return new Intl.DateTimeFormat(
34      i18n.language,
35      formatOptions[format]
36    ).format(new Date(date));
37  };
38
39  return <span>{formatDate(date, format)}</span>;
40}
41
42// Użycie
43function EventCard({ event }) {
44  return (
45    <div className="event-card">
46      <h3>{event.title}</h3>
47      <DateFormatter date={event.startDate} format="long" />
48      <DateFormatter date={event.startTime} format="time" />
49    </div>
50  );
51}

Kolejność dnia, miesiąca i roku oraz nazwy miesięcy dopasują się do języka same, bez żadnego if.

Liczby i waluty obsługuje analogicznie Intl.NumberFormat:

1// NumberFormatter.jsx
2import { useTranslation } from 'react-i18next';
3
4function NumberFormatter({
5  value,
6  type = 'decimal',
7  currency = 'USD',
8  minimumFractionDigits = 0,
9  maximumFractionDigits = 2
10}) {
11  const { i18n } = useTranslation();
12
13  const formatNumber = (value, type) => {
14    const options = {
15      minimumFractionDigits,
16      maximumFractionDigits
17    };
18
19    if (type === 'currency') {
20      options.style = 'currency';
21      options.currency = currency;
22    } else if (type === 'percent') {
23      options.style = 'percent';
24    }
25
26    return new Intl.NumberFormat(i18n.language, options).format(value);
27  };
28
29  return <span>{formatNumber(value, type)}</span>;
30}
31
32// PriceDisplay.jsx
33function PriceDisplay({ price, currency, originalPrice }) {
34  const { t } = useTranslation();
35
36  return (
37    <div className="price-display">
38      <span className="current-price">
39        <NumberFormatter
40          value={price}
41          type="currency"
42          currency={currency}
43        />
44      </span>
45
46      {originalPrice && originalPrice > price && (
47        <span className="original-price">
48          <NumberFormatter
49            value={originalPrice}
50            type="currency"
51            currency={currency}
52          />
53        </span>
54      )}
55
56      {originalPrice && originalPrice > price && (
57        <span className="discount">
58          {t('product.discount', {
59            percent: ((originalPrice - price) / originalPrice * 100).toFixed(0)
60          })}
61        </span>
62      )}
63    </div>
64  );
65}

Liczba 12345.5 to po polsku 12 345,5, a po angielsku 12,345.5. Waluta nie zmienia się razem z językiem, zmienia się tylko zapis.

Leniwe ładowanie tłumaczeń

LazyTranslation doładowuje przestrzeń przez loadNamespaces dopiero wtedy, gdy ekran jej potrzebuje, a w tym czasie pokazuje komunikat:

1// LazyTranslation.jsx
2import { useState, useEffect } from 'react';
3import { useTranslation } from 'react-i18next';
4
5function LazyTranslation({ namespace, children }) {
6  const { i18n, ready } = useTranslation(namespace, { useSuspense: false });
7  const [isLoading, setIsLoading] = useState(!ready);
8
9  useEffect(() => {
10    if (!ready) {
11      i18n.loadNamespaces(namespace).then(() => {
12        setIsLoading(false);
13      });
14    } else {
15      setIsLoading(false);
16    }
17  }, [namespace, ready, i18n]);
18
19  if (isLoading) {
20    return <div className="loading">Ładowanie tłumaczeń...</div>;
21  }
22
23  return children;
24}
25
26// Użycie
27function ProductPage() {
28  return (
29    <LazyTranslation namespace="products">
30      <ProductDetails />
31      <ProductReviews />
32    </LazyTranslation>
33  );
34}

Flaga ready mówi, czy tłumaczenia dotarły. Ten wariant wyłącza Suspense opcją useSuspense: false, bo domyślnie hook sam wstrzymuje komponent.

Dlatego wygodniej użyć Suspense: useTranslation z domyślnym useSuspense: true zawiesza komponent do chwili załadowania przestrzeni:

1// TranslationSuspense.jsx
2import { Suspense } from 'react';
3import { useTranslation } from 'react-i18next';
4
5function TranslationLoader({ namespace, children }) {
6  const { ready } = useTranslation(namespace, { useSuspense: true });
7
8  if (!ready) {
9    throw new Promise((resolve) => {
10      // Symulacja ładowania
11      setTimeout(resolve, 100);
12    });
13  }
14
15  return children;
16}
17
18function App() {
19  return (
20    <div>
21      <Suspense fallback={<div>Ładowanie tłumaczeń...</div>}>
22        <TranslationLoader namespace="common">
23          <Header />
24        </TranslationLoader>
25      </Suspense>
26
27      <Suspense fallback={<div>Ładowanie treści strony...</div>}>
28        <TranslationLoader namespace="dashboard">
29          <Dashboard />
30        </TranslationLoader>
31      </Suspense>
32    </div>
33  );
34}

Ręczne rzucanie obietnicy w TranslationLoader tylko ilustruje mechanizm, bo hook i tak wstrzyma renderowanie. Każda granica Suspense ładuje się niezależnie.

SEO i SSR

Przy SSR tłumaczenia muszą być gotowe, zanim HTML opuści serwer, inaczej wyszukiwarka zobaczy klucze. Przykład używa next-i18next 16 z Pages Routerem, którego API importujesz z next-i18next/pages:

1// next-i18next.config.js
2module.exports = {
3  i18n: {
4    defaultLocale: 'en',
5    locales: ['en', 'pl', 'es', 'fr', 'de']
6  },
7  localePath: './public/locales',
8  defaultNS: 'common',
9  fallbackLng: {
10    'en-US': ['en'],
11    'pl-PL': ['pl'],
12    default: ['en']
13  }
14};
15
16// pages/_app.js
17import { appWithTranslation } from 'next-i18next/pages';
18import { LanguageProvider } from '../components/LanguageProvider';
19
20function MyApp({ Component, pageProps }) {
21  return (
22    <LanguageProvider>
23      <Component {...pageProps} />
24    </LanguageProvider>
25  );
26}
27
28export default appWithTranslation(MyApp);
29
30// pages/index.js
31import { serverSideTranslations } from 'next-i18next/pages/serverSideTranslations';
32import { useTranslation } from 'next-i18next/pages';
33
34function HomePage() {
35  const { t } = useTranslation('common');
36
37  return (
38    <div>
39      <h1>{t('welcome.title')}</h1>
40      <p>{t('welcome.description')}</p>
41    </div>
42  );
43}
44
45export async function getStaticProps({ locale }) {
46  return {
47    props: {
48      ...(await serverSideTranslations(locale, ['common', 'navigation']))
49    }
50  };
51}
52
53export default HomePage;

serverSideTranslations ładuje przestrzenie na serwerze, a appWithTranslation przekazuje je aplikacji. Obiekt i18n z konfiguracji trafia też do next.config.js, a Next.js przyjmuje w nim tylko własne pola, jak locales i defaultLocale, dlatego opcje biblioteki (localePath, defaultNS, fallbackLng) leżą obok niego. Dla App Routera wersja 16 ma osobne API: getT() w komponentach serwerowych i useT() w klienckich.

LocalizedHead tłumaczy meta tagi i mówi wyszukiwarce, że strona istnieje w kilku językach:

1// LocalizedHead.jsx
2import Head from 'next/head';
3import { useTranslation } from 'next-i18next/pages';
4import { useRouter } from 'next/router';
5
6function LocalizedHead({
7  title,
8  description,
9  keywords,
10  ogImage,
11  canonical
12}) {
13  const { t, i18n } = useTranslation('meta');
14  const router = useRouter();
15
16  const localizedTitle = title ? t(title) : t('default.title');
17  const localizedDescription = description ? t(description) : t('default.description');
18  const localizedKeywords = keywords ? t(keywords) : t('default.keywords');
19
20  const currentUrl = `https://example.com${router.asPath}`;
21  const canonicalUrl = canonical || currentUrl;
22
23  return (
24    <Head>
25      <title>{localizedTitle}</title>
26      <meta name="description" content={localizedDescription} />
27      <meta name="keywords" content={localizedKeywords} />
28      <meta name="language" content={i18n.language} />
29
30      {/* Open Graph */}
31      <meta property="og:title" content={localizedTitle} />
32      <meta property="og:description" content={localizedDescription} />
33      <meta property="og:url" content={currentUrl} />
34      <meta property="og:locale" content={i18n.language} />
35      <meta property="og:image" content={ogImage} />
36
37      {/* Twitter Card */}
38      <meta name="twitter:title" content={localizedTitle} />
39      <meta name="twitter:description" content={localizedDescription} />
40
41      {/* Canonical URL */}
42      <link rel="canonical" href={canonicalUrl} />
43
44      {/* Alternate language versions */}
45      {i18n.options.locales?.map(locale => (
46        <link
47          key={locale}
48          rel="alternate"
49          hrefLang={locale}
50          href={`https://example.com/${locale}${router.asPath}`}
51        />
52      ))}
53    </Head>
54  );
55}

Link rel="alternate" z atrybutem hrefLang wskazuje każdą wersję językową, a og:locale podaje język serwisom społecznościowym.

Testy

W testach nie ładujesz plików przez HTTP. Własny render owija komponent w I18nextProvider, a konfiguracja testowa podaje tłumaczenia przez resources i ustawia lng: 'en':

1// test-utils.js
2import { render } from '@testing-library/react';
3import { I18nextProvider } from 'react-i18next';
4import i18n from './i18n-test-config';
5
6const AllTheProviders = ({ children }) => {
7  return (
8    <I18nextProvider i18n={i18n}>
9      {children}
10    </I18nextProvider>
11  );
12};
13
14const customRender = (ui, options) =>
15  render(ui, { wrapper: AllTheProviders, ...options });
16
17export * from '@testing-library/react';
18export { customRender as render };
19
20// i18n-test-config.js
21import i18n from 'i18next';
22import { initReactI18next } from 'react-i18next';
23
24i18n.use(initReactI18next).init({
25  lng: 'en',
26  fallbackLng: 'en',
27  defaultNS: 'common',
28  debug: false,
29  interpolation: {
30    escapeValue: false
31  },
32  resources: {
33    en: {
34      common: {
35        'welcome.title': 'Welcome',
36        'button.submit': 'Submit'
37      }
38    },
39    pl: {
40      common: {
41        'welcome.title': 'Witaj'
42      }
43    }
44  }
45});
46
47export default i18n;
48
49// Component.test.js
50import { render, screen } from './test-utils';
51import { act } from '@testing-library/react';
52import i18n from './i18n-test-config';
53import WelcomeComponent from './WelcomeComponent';
54
55describe('WelcomeComponent', () => {
56  test('renders welcome message', () => {
57    render(<WelcomeComponent />);
58
59    expect(screen.getByText('Welcome')).toBeInTheDocument();
60  });
61
62  test('changes language', async () => {
63    const { rerender } = render(<WelcomeComponent />);
64
65    // Zmień język
66    await act(async () => {
67      await i18n.changeLanguage('pl');
68    });
69
70    rerender(<WelcomeComponent />);
71
72    expect(screen.getByText('Witaj')).toBeInTheDocument();
73  });
74});

To ten sam łańcuch use(initReactI18next).init() co w aplikacji, tylko bez backendu. defaultNS: 'common' wskazuje przestrzeń z kluczami testowymi, a resources zawiera oba języki, które sprawdza test. Test zmiany języka czeka w act, aż React przerenderuje komponent.

Wydajność

Przy długich listach OptimizedTranslation łączy memo z useMemo, a useTranslationCache trzyma gotowe teksty w mapie:

1// OptimizedTranslation.jsx
2import { memo, useMemo, useCallback } from 'react';
3import { useTranslation } from 'react-i18next';
4
5const OptimizedTranslation = memo(function OptimizedTranslation({
6  translationKey,
7  values,
8  namespace = 'common'
9}) {
10  const { t } = useTranslation(namespace);
11
12  const translatedText = useMemo(() => {
13    return t(translationKey, values);
14  }, [t, translationKey, values]);
15
16  return <span>{translatedText}</span>;
17});
18
19// TranslationCache.jsx - Custom hook z cache
20function useTranslationCache() {
21  const { t, i18n } = useTranslation();
22  const cache = useMemo(() => new Map(), [i18n.language]);
23
24  const getCachedTranslation = useCallback((key, options) => {
25    const cacheKey = JSON.stringify({ key, options });
26
27    if (cache.has(cacheKey)) {
28      return cache.get(cacheKey);
29    }
30
31    const translation = t(key, options);
32    cache.set(cacheKey, translation);
33
34    return translation;
35  }, [t, cache]);
36
37  return getCachedTranslation;
38}

Cache powstaje od nowa przy zmianie i18n.language, więc stare teksty nie przeciekną do nowego języka. Najpierw jednak zmierz, bo zwykle to przedwczesna optymalizacja.

Pliki tłumaczeń możesz wydzielić do osobnej paczki albo ładować dynamicznym import():

1// webpack.config.js
2module.exports = {
3  optimization: {
4    splitChunks: {
5      cacheGroups: {
6        i18n: {
7          test: /[\\/]locales[\\/]/,
8          name: 'i18n',
9          chunks: 'all'
10        }
11      }
12    }
13  }
14};
15
16// Dynamic imports dla tłumaczeń
17const loadTranslations = async (language) => {
18  const translations = await import(`../locales/${language}/common.json`);
19  return translations.default;
20};

splitChunks zbiera katalog locales w plik i18n, a loadTranslations pobiera naraz tylko jeden język.

Najlepsze praktyki

Klucze grupuj według miejsca w aplikacji, czyli stron, komponentów i wspólnych akcji:

1// Dobra struktura
2{
3  "pages": {
4    "home": {
5      "title": "Home Page",
6      "meta": {
7        "description": "Welcome to our homepage"
8      }
9    },
10    "about": {
11      "title": "About Us",
12      "sections": {
13        "team": "Our Team",
14        "history": "Company History"
15      }
16    }
17  },
18  "components": {
19    "navigation": {
20      "menu": "Menu",
21      "close": "Close"
22    },
23    "forms": {
24      "validation": {
25        "required": "This field is required",
26        "email": "Please enter a valid email"
27      }
28    }
29  },
30  "common": {
31    "actions": {
32      "save": "Save",
33      "cancel": "Cancel",
34      "delete": "Delete"
35    }
36  }
37}

Wspólne słowa, jak Save czy Cancel, trzymasz wtedy w jednym miejscu: common.actions.

Własny hook ogranicza t() do kluczy z interfejsu TranslationKeys, więc literówka zatrzyma kompilację:

1// types/i18n.ts
2export interface TranslationKeys {
3  'welcome.title': string;
4  'welcome.description': string;
5  'navigation.home': string;
6  'navigation.about': string;
7  'forms.name.label': string;
8  'forms.email.label': string;
9}
10
11// Custom hook z typowaniem
12import { useTranslation as useI18nTranslation } from 'react-i18next';
13
14export function useTranslation(namespace?: string) {
15  const { t, ...rest } = useI18nTranslation(namespace);
16
17  return {
18    t: (key: keyof TranslationKeys, options?: any) => t(key, options),
19    ...rest
20  };
21}
22
23// Komponenty z typowaniem
24interface TranslatedTextProps {
25  translationKey: keyof TranslationKeys;
26  values?: Record<string, any>;
27}
28
29function TranslatedText({ translationKey, values }: TranslatedTextProps) {
30  const { t } = useTranslation();
31  return <span>{t(translationKey, values)}</span>;
32}

Dokumentacja react-i18next opisuje też pełne typowanie przez interfejs CustomTypeOptions, który wyprowadza klucze z plików JSON. W większym projekcie polecam ten sposób.

Organizację tłumaczeń, wydajność i testy scenariuszy językowych sprawdzisz w ćwiczeniu, budując wielojęzyczny dashboard kosmiczny.

Pamiętaj: komponent zna tylko klucze, a pliki tłumaczeń to słowniki tłumacza pokładowego - wymieniasz słownik, a statek mówi nowym językiem.

Kod do tej lekcji: App.jsx
1import React, { useState, createContext, useContext } from 'react';
2
3// Symulacja biblioteki i18n - system tłumaczeń
4const translations = {
5  pl: {
6    welcome: 'Witaj na stacji kosmicznej!',
7    language: 'Język',
8    mission: 'Aktualna misja',
9    missionDesc: 'Eksploracja nowych galaktyk',
10    crew: 'Załoga',
11    crewMembers: '{count} członków',
12    status: 'Status systemu',
13    online: 'Online',
14    fuel: 'Paliwo: {level}%',
15    date: 'Data gwiezdna',
16  },
17  en: {
18    welcome: 'Welcome to the space station!',
19    language: 'Language',
20    mission: 'Current mission',
21    missionDesc: 'Exploring new galaxies',
22    crew: 'Crew',
23    crewMembers: '{count} members',
24    status: 'System status',
25    online: 'Online',
26    fuel: 'Fuel: {level}%',
27    date: 'Star date',
28  },
29  de: {
30    welcome: 'Willkommen auf der Raumstation!',
31    language: 'Sprache',
32    mission: 'Aktuelle Mission',
33    missionDesc: 'Erforschung neuer Galaxien',
34    crew: 'Besatzung',
35    crewMembers: '{count} Mitglieder',
36    status: 'Systemstatus',
37    online: 'Online',
38    fuel: 'Treibstoff: {level}%',
39    date: 'Sternendatum',
40  },
41};
42
43// Kontekst i18n
44const I18nContext = createContext(null);
45
46function I18nProvider({ children }) {
47  const [locale, setLocale] = useState('pl');
48
49  const t = (key, params = {}) => {
50    let text = translations[locale]?.[key] || key;
51    Object.entries(params).forEach(([k, v]) => {
52      text = text.replace('{' + k + '}', v);
53    });
54    return text;
55  };
56
57  return (
58    <I18nContext.Provider value={{ locale, setLocale, t }}>
59      {children}
60    </I18nContext.Provider>
61  );
62}
63
64function useTranslation() {
65  return useContext(I18nContext);
66}
67
68function LanguageSwitcher() {
69  const { locale, setLocale } = useTranslation();
70  const languages = [
71    { code: 'pl', flag: 'PL', label: 'Polski' },
72    { code: 'en', flag: 'EN', label: 'English' },
73    { code: 'de', flag: 'DE', label: 'Deutsch' },
74  ];
75
76  return (
77    <div style={{ display: 'flex', gap: '8px' }}>
78      {languages.map(lang => (
79        <button
80          key={lang.code}
81          onClick={() => setLocale(lang.code)}
82          style={{
83            padding: '8px 16px',
84            border: locale === lang.code ? '2px solid #00d4ff' : '1px solid #415a77',
85            borderRadius: '8px',
86            background: locale === lang.code ? '#1b2838' : 'transparent',
87            color: locale === lang.code ? '#00d4ff' : '#778da9',
88            cursor: 'pointer',
89            fontWeight: locale === lang.code ? 'bold' : 'normal',
90          }}
91        >
92          {lang.flag} {lang.label}
93        </button>
94      ))}
95    </div>
96  );
97}
98
99function SpaceStationDashboard() {
100  const { t, locale } = useTranslation();
101  const crewCount = 7;
102  const fuelLevel = 82;
103  const starDate = new Date().toLocaleDateString(locale, {
104    year: 'numeric', month: 'long', day: 'numeric',
105  });
106
107  return (
108    <div style={{ background: '#0d1b2a', minHeight: '100vh', padding: '24px', color: '#e0e1dd', fontFamily: 'monospace' }}>
109      <div style={{ maxWidth: '600px', margin: '0 auto' }}>
110        <h1 style={{ color: '#00d4ff', marginBottom: '8px' }}>{t('welcome')}</h1>
111
112        <div style={{ marginBottom: '24px' }}>
113          <h3 style={{ color: '#778da9', marginBottom: '8px' }}>{t('language')}</h3>
114          <LanguageSwitcher />
115        </div>
116
117        <div style={{ display: 'grid', gap: '16px' }}>
118          <div style={{ background: '#1b2838', padding: '16px', borderRadius: '12px', border: '1px solid #415a77' }}>
119            <h3 style={{ color: '#00d4ff', margin: '0 0 8px' }}>{t('mission')}</h3>
120            <p style={{ margin: 0, color: '#e0e1dd' }}>{t('missionDesc')}</p>
121          </div>
122
123          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
124            <div style={{ background: '#1b2838', padding: '16px', borderRadius: '12px', border: '1px solid #415a77' }}>
125              <h4 style={{ color: '#778da9', margin: '0 0 4px' }}>{t('crew')}</h4>
126              <p style={{ color: '#00d4ff', fontSize: '20px', margin: 0 }}>
127                {t('crewMembers', { count: crewCount })}
128              </p>
129            </div>
130            <div style={{ background: '#1b2838', padding: '16px', borderRadius: '12px', border: '1px solid #415a77' }}>
131              <h4 style={{ color: '#778da9', margin: '0 0 4px' }}>{t('status')}</h4>
132              <p style={{ color: '#00ff88', fontSize: '20px', margin: 0 }}>{t('online')}</p>
133            </div>
134          </div>
135
136          <div style={{ background: '#1b2838', padding: '16px', borderRadius: '12px', border: '1px solid #415a77' }}>
137            <h4 style={{ color: '#778da9', margin: '0 0 4px' }}>{t('fuel', { level: fuelLevel })}</h4>
138            <div style={{ background: '#0d1b2a', borderRadius: '8px', height: '20px', overflow: 'hidden' }}>
139              <div style={{ width: fuelLevel + '%', height: '100%', background: 'linear-gradient(90deg, #00d4ff, #00ff88)', borderRadius: '8px' }} />
140            </div>
141          </div>
142
143          <div style={{ background: '#1b2838', padding: '16px', borderRadius: '12px', border: '1px solid #415a77' }}>
144            <h4 style={{ color: '#778da9', margin: '0 0 4px' }}>{t('date')}</h4>
145            <p style={{ color: '#e0e1dd', fontSize: '18px', margin: 0 }}>{starDate}</p>
146          </div>
147        </div>
148      </div>
149    </div>
150  );
151}
152
153export default function App() {
154  return (
155    <I18nProvider>
156      <SpaceStationDashboard />
157    </I18nProvider>
158  );
159}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Do czego służy hook useTranslation z biblioteki react-i18next?

  2. 2. Jak react-i18next obsługuje pluralizację (np. '1 element' vs '5 elementów')?

Zadania praktyczne w grze

  • Klikanie w kolejności

    Kliknij elementy w kolejności inicjalizacji i18next w aplikacji React:

  • Edytor kodu

    Uzupełnij mini wersję funkcji t() z i18next w kosmicznym dashboardzie (PL, EN, DE). ___BLANK1___: przekaż do new Intl.PluralRules(...) kod aktualnego języka, żeby polski wybierał formy one, few i many, a angielski i niemiecki one i other. ___BLANK2___: w miejsce {{name}} i {{count}} wstaw wartość z obiektu options (name to nazwa zmiennej z podwójnych nawiasów, np. count). ___BLANK3___: przycisk języka zapisuje w stanie swój kod (pl, en albo de). W podglądzie zobaczysz kolejno: 1 aktywna misja, 2 aktywne misje, 5 aktywnych misji, a po przełączeniu na EN: 5 active missions.

Przydatne artykuły