JavaScript and React course · Module 10: React Ecosystem and Future
React-i18next - Internationalizing React Applications
In this lesson11
A crew from all over the galaxy is boarding: the captain reads messages in Polish, the navigator in English and the engineer in Spanish. If the texts are hard-coded in JSX, every new language means rewriting components. The solution is internationalization (i18n): the component knows only keys, and the translations live in separate files. React-i18next connects React with the i18next library, which also formats dates, numbers and currencies.
Installation and Configuration
You install the i18next core, the react-i18next integration and two optional plugins: a language detector and an HTTP backend:
1npm install react-i18next i18next i18next-browser-languagedetector i18next-http-backendThe configuration is a chain: each use() plugs in a plugin, init() sets the options, and at the end you export the instance:
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) // Load translations from server
9 .use(LanguageDetector) // Automatic language detection
10 .use(initReactI18next) // React integration
11 .init({
12 // Fallback configuration
13 fallbackLng: 'en',
14
15 // Debugging in development
16 debug: process.env.NODE_ENV === 'development',
17
18 // Interpolation
19 interpolation: {
20 escapeValue: false // React already protects against XSS
21 },
22
23 // Backend configuration
24 backend: {
25 loadPath: '/locales/{{lng}}/{{ns}}.json'
26 },
27
28 // Language detector configuration
29 detection: {
30 order: ['localStorage', 'navigator', 'htmlTag'],
31 caches: ['localStorage']
32 }
33 });
34
35export default i18n;fallbackLng: 'en' is the fallback language, and escapeValue: false turns off double escaping, because React protects against XSS on its own. In a simpler project, instead of a backend you pass translations directly: init({ resources, lng }).
You import the configuration once, in the entry file, before rendering the application:
1// index.js
2import React from 'react';
3import ReactDOM from 'react-dom/client';
4import './i18n'; // Import configuration
5import App from './App';
6
7const root = ReactDOM.createRoot(document.getElementById('root'));
8root.render(<App />);The import only initializes i18next, so App receives nothing through props.
The useTranslation Hook
In a component you reach for useTranslation. The hook returns a t() function that turns a key into text, and an i18n object with a changeLanguage method:
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;The welcome.title key points to a nested field in the JSON. After changeLanguage('pl'), every component using this hook re-renders with the new texts.
Translation files have identical keys and differ only in their values:
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}The backend will find them at the path /locales/{{lng}}/{{ns}}.json, where lng is the language and ns the namespace.
Interpolation and Pluralization
You pass data, such as a name or a message count, as the second argument of the t() function:
1function UserProfile({ user, messageCount }) {
2 const { t } = useTranslation();
3
4 return (
5 <div>
6 {/* Interpolation */}
7 <h1>{t('user.greeting', { name: user.name })}</h1>
8
9 {/* Pluralization */}
10 <p>{t('messages.count', { count: messageCount })}</p>
11
12 {/* Date formatting */}
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}} in the translation will be replaced with the value of user.name. For plurals, the variable must be named exactly count. The date is formatted by {{date, datetime}} in the translation, and formatParams passes the Intl.DateTimeFormat options to it.
Plural forms are distinguished by key suffixes:
1// Translation files with pluralization
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}Since version 21, i18next picks the suffix through Intl.PluralRules: English has the _one and _other forms, and _zero is an extra variant for zero. Polish needs _one (1 wiadomość), _few (2 wiadomości), _many (5 wiadomości) and _other (fractions). The component does not change, only the files do.
Namespaces
Namespaces split a huge translation file into readable modules, e.g. navigation, forms and errors:
1// Configuration with namespaces
2i18n.init({
3 // Default namespace and the list of all namespaces
4 defaultNS: 'common',
5 ns: ['common', 'navigation', 'forms', 'errors']
6});
7
8// Using different namespaces
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') selects the namespace for the component, and the forms:name.label notation reaches for a key from a specific namespace.
Language Context
A language switcher usually needs to save the choice, a list of languages and the text direction. LanguageProvider gathers all of that:
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 // Save choice in localStorage
16 localStorage.setItem('preferredLanguage', newLang);
17
18 // Change text direction for RTL languages
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}For Arabic, Hebrew and Persian the provider sets document.dir = 'rtl'. LanguageSelector is a plain select controlled from the context.
Dates, Numbers and Currencies
Every country writes dates differently, so instead of formatting by hand use Intl.DateTimeFormat with the language from 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// Usage
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}The order of day, month and year, as well as the month names, adapt to the language by themselves, without a single if.
Numbers and currencies are handled the same way by 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}The number 12345.5 is written 12 345,5 in Polish and 12,345.5 in English. The currency does not change with the language, only the notation does.
Lazy Loading Translations
LazyTranslation loads a namespace through loadNamespaces only when the screen needs it, and shows a message in the meantime:
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">Loading translations...</div>;
21 }
22
23 return children;
24}
25
26// Usage
27function ProductPage() {
28 return (
29 <LazyTranslation namespace="products">
30 <ProductDetails />
31 <ProductReviews />
32 </LazyTranslation>
33 );
34}The ready flag tells you whether the translations have arrived. This variant turns Suspense off with the useSuspense: false option, because by default the hook suspends the component itself.
That is why Suspense is more convenient: useTranslation with the default useSuspense: true suspends the component until the namespace has loaded:
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 // Simulate loading
11 setTimeout(resolve, 100);
12 });
13 }
14
15 return children;
16}
17
18function App() {
19 return (
20 <div>
21 <Suspense fallback={<div>Loading translations...</div>}>
22 <TranslationLoader namespace="common">
23 <Header />
24 </TranslationLoader>
25 </Suspense>
26
27 <Suspense fallback={<div>Loading page content...</div>}>
28 <TranslationLoader namespace="dashboard">
29 <Dashboard />
30 </TranslationLoader>
31 </Suspense>
32 </div>
33 );
34}Throwing the promise by hand in TranslationLoader only illustrates the mechanism, because the hook suspends rendering anyway. Each Suspense boundary loads independently.
SEO and SSR
With SSR, translations must be ready before the HTML leaves the server, otherwise the search engine will see the keys. The example uses next-i18next 16 with the Pages Router, whose API you import from 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 loads the namespaces on the server, and appWithTranslation passes them to the application. The i18n object from the config also goes into next.config.js, and Next.js accepts only its own fields there, such as locales and defaultLocale, so the library options (localePath, defaultNS, fallbackLng) sit next to it. For the App Router, version 16 has a separate API: getT() in Server Components and useT() in Client Components.
LocalizedHead translates the meta tags and tells the search engine that the page exists in several languages:
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}A rel="alternate" link with the hrefLang attribute points to each language version, and og:locale gives the language to social networks.
Testing
In tests you do not load files over HTTP. A custom render wraps the component in I18nextProvider, and the test configuration supplies translations through resources and sets 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 // Change the language
66 await act(async () => {
67 await i18n.changeLanguage('pl');
68 });
69
70 rerender(<WelcomeComponent />);
71
72 expect(screen.getByText('Witaj')).toBeInTheDocument();
73 });
74});It is the same use(initReactI18next).init() chain as in the application, just without the backend. defaultNS: 'common' points to the namespace with the test keys, and resources contains both languages the test checks. The language change test waits in act until React re-renders the component.
Performance
For long lists, OptimizedTranslation combines memo with useMemo, and useTranslationCache keeps ready texts in a map:
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 with 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}The cache is rebuilt from scratch when i18n.language changes, so old texts will not leak into the new language. Measure first, though, because this is usually premature optimization.
You can move translation files into a separate bundle or load them with a dynamic 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 for translations
17const loadTranslations = async (language) => {
18 const translations = await import(`../locales/${language}/common.json`);
19 return translations.default;
20};splitChunks gathers the locales directory into an i18n file, and loadTranslations fetches only one language at a time.
Best Practices
Group keys by their place in the application, meaning pages, components and shared actions:
1// Good structure
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}Shared words, such as Save or Cancel, then live in one place: common.actions.
A custom hook limits t() to the keys from the TranslationKeys interface, so a typo stops the compilation:
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 with typing
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// Components with typing
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}The react-i18next documentation also describes full typing through the CustomTypeOptions interface, which derives keys from the JSON files. In a larger project I recommend that approach.
You will practice organizing translations, performance and testing language scenarios in the exercise, by building a multilingual space dashboard.
Remember: the component knows only keys, and the translation files are the dictionaries of the ship's translator - swap the dictionary and the ship speaks a new language.
Code for this lesson: App.jsx
1import React, { useState, createContext, useContext } from 'react';
2
3// Simulation of an i18n library - translation system
4const translations = {
5 en: {
6 welcome: 'Welcome to the space station!',
7 language: 'Language',
8 mission: 'Current mission',
9 missionDesc: 'Exploring new galaxies',
10 crew: 'Crew',
11 crewMembers: '{count} members',
12 status: 'System status',
13 online: 'Online',
14 fuel: 'Fuel: {level}%',
15 date: 'Star date',
16 },
17 es: {
18 welcome: '¡Bienvenidos a la estación espacial!',
19 language: 'Idioma',
20 mission: 'Misión actual',
21 missionDesc: 'Exploración de nuevas galaxias',
22 crew: 'Tripulación',
23 crewMembers: '{count} miembros',
24 status: 'Estado del sistema',
25 online: 'En línea',
26 fuel: 'Combustible: {level}%',
27 date: 'Fecha estelar',
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// i18n context
44const I18nContext = createContext(null);
45
46function I18nProvider({ children }) {
47 const [locale, setLocale] = useState('en');
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: 'en', flag: 'EN', label: 'English' },
72 { code: 'es', flag: 'ES', label: 'Español' },
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}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is the useTranslation hook from the react-i18next library used for?
2. How does react-i18next handle pluralization (e.g., '1 item' vs '5 items')?
Hands-on tasks in the game
- Click in order
Click the elements in the order of i18next initialization in a React application:
- Code editor
Complete a mini version of the i18next t() function in a space dashboard (EN, PL, DE). ___BLANK1___: pass the code of the current language to new Intl.PluralRules(...), so that Polish picks the forms one, few and many, while English and German pick one and other. ___BLANK2___: replace {{name}} and {{count}} with the value from the options object (name is the variable name from the double braces, e.g. count). ___BLANK3___: a language button stores its code (en, pl or de) in state. In the preview you will see 1 active mission, 2 active missions, 5 active missions, and after switching to PL: 5 aktywnych misji.