I18n Best Practices
Follow these best practices to build scalable, maintainable multi-language applications.
Do's ✅
1. Use Dot Notation Keys
// ✅ Good - organized and nested
t('common.welcome')
t('pages.home.title')
t('ui.forms.email.label')
// ❌ Avoid - flat structure gets messy
t('welcome')
t('homeTitle')
t('emailLabel')
2. Type Your Language Enum
// ✅ Good - explicit type prevents typos
type Language = 'en' | 'es' | 'fr' | 'de';
// ❌ Avoid - string types allow any value
export const useTranslation = createI18n<any, string>({
// ...
});
3. Handle Loading States
// ✅ Good - check before rendering
const { t, isInitialLoading } = useTranslation();
if (isInitialLoading) {
return <LoadingSpinner />;
}
return <h1>{t('common.welcome')}</h1>;
// ❌ Avoid - showing raw keys on load
<h1>{t('common.welcome')}</h1> // May show "common.welcome"
4. Persist Language Preference
// ✅ Good - remember user choice
const handleLanguageChange = (newLan: Language) => {
updateTranslation(newLan);
localStorage.setItem('preferred-language', newLan);
};
// ❌ Avoid - reset on every page load
updateTranslation(browserLanguage); // User choice lost
5. Organize by Feature
// ✅ Good - grouped by feature
{
pages: {
home: { title: '...', description: '...' },
about: { title: '...', description: '...' },
},
ui: {
buttons: { save: '...', cancel: '...' },
forms: { email: { label: '...', error: '...' } },
},
}
// ❌ Avoid - flat unorganized structure
{
homePage: '...',
aboutPage: '...',
homeDescription: '...',
saveButton: '...',
cancelButton: '...',
}
6. Keep Translations in Sync
// ✅ Good - all languages have same keys
export const en = { common: { welcome: 'Welcome' } };
export const es = { common: { welcome: 'Bienvenido' } };
export const fr = { common: { welcome: 'Bienvenue' } };
// ❌ Avoid - missing keys in some languages
export const en = { common: { welcome: 'Welcome', bye: 'Goodbye' } };
export const es = { common: { welcome: 'Bienvenido' } }; // bye is missing!
7. Use Loading State in UI
// ✅ Good - disable during language switch
const { isUpdating } = useTranslation();
<select disabled={isUpdating}>
{/* Options */}
</select>
// ❌ Avoid - allow interaction during load
<select>
{/* Options */}
</select>
8. Catch Resource Errors
// ✅ Good - handle fetch errors
resource: async (lan) => {
try {
const res = await fetch(`/translations/${lan}.json`);
if (!res.ok) throw new Error('Failed to load');
return res.json();
} catch (error) {
console.error(`Failed to load ${lan}:`, error);
return defaultTranslations[lan];
}
}
// ❌ Avoid - let errors propagate
resource: async (lan) => {
const res = await fetch(`/translations/${lan}.json`);
return res.json(); // Will crash on error
}
Don'ts ❌
1. Don't Use Dynamic Keys
// ❌ Bad - loses type safety
const key = 'common.' + userInput;
t(key); // No autocomplete!
// ✅ Good - static keys with type checking
t('common.welcome');
2. Don't Forget Initial Loading State
// ❌ Bad - data might be null
const { data } = useTranslation();
console.log(data.common.welcome); // May crash!
// ✅ Good - check loading state first
const { t, isInitialLoading } = useTranslation();
if (isInitialLoading) return null;
console.log(t('common.welcome')); // Safe
3. Don't Ignore isUpdating State
// ❌ Bad - user can click during load
<button onClick={switchLanguage}>
Switch Language
</button>
// ✅ Good - disable during update
const { isUpdating } = useTranslation();
<button disabled={isUpdating} onClick={switchLanguage}>
Switch Language
</button>
4. Don't Store Translations in Component State
// ❌ Bad - duplicates state
const { t } = useTranslation();
const [cachedTranslations, setCached] = useState(t);
// ✅ Good - use hook directly
const { t } = useTranslation();
// Use t directly, no caching needed
5. Don't Mix Multiple i18n Instances
// ❌ Bad - confusing and wasteful
const useTranslation1 = createI18n({...});
const useTranslation2 = createI18n({...});
// ✅ Good - single instance per app
export const useTranslation = createI18n({...});
// Use everywhere
6. Don't Leave Keys Untranslated
// ❌ Bad - missing Spanish translations
export const es = {
common: {
welcome: 'Bienvenido',
// goodbye is missing!
},
};
// ✅ Good - complete translations
export const es = {
common: {
welcome: 'Bienvenido',
goodbye: 'Adiós',
},
};
7. Don't Hardcode Language Selection
// ❌ Bad - user preference ignored
<select value="en" disabled>
<option>English</option>
</select>
// ✅ Good - allow user to switch
const { lan, updateTranslation } = useTranslation();
<select value={lan} onChange={(e) => updateTranslation(e.target.value)}>
<option value="en">English</option>
<option value="es">Español</option>
</select>
8. Don't Trust Untranslated Content
// ❌ Bad - translation might be missing
<h1>{t('pages.unknownPage.title') || 'Default Title'}</h1>
// ✅ Good - ensure key exists at build time
<h1>{t('pages.home.title')}</h1> // TypeScript validates
Common Patterns
Pattern 1: Context-Based Translations
Use Context to avoid prop drilling:
import { createContext, useContext } from 'react';
import { useTranslation } from './i18n';
const I18nContext = createContext<ReturnType<typeof useTranslation> | null>(null);
export function I18nProvider({ children }: { children: React.ReactNode }) {
const translation = useTranslation();
return (
<I18nContext.Provider value={translation}>
{children}
</I18nContext.Provider>
);
}
export function useI18n() {
const context = useContext(I18nContext);
if (!context) throw new Error('useI18n must be used within I18nProvider');
return context;
}
Pattern 2: SEO-Friendly Routes
// Implement language in URL path for better SEO
// /en/about, /es/about, /fr/about
export default function Layout({ params }: { params: { lang: string } }) {
return (
<html lang={params.lang}>
<body>{/* content */}</body>
</html>
);
}
Pattern 3: Memoized Switcher
import { memo } from 'react';
const LanguageSwitcher = memo(function LanguageSwitcher() {
const { lan, updateTranslation, isUpdating } = useTranslation();
return (
<select
value={lan}
onChange={(e) => updateTranslation(e.target.value)}
disabled={isUpdating}
>
{/* Options */}
</select>
);
});
export default LanguageSwitcher;
Pattern 4: Cached Loading
const translationCache = new Map<string, any>();
export const useTranslation = createI18n({
initialLan: 'en',
resource: async (lan) => {
if (translationCache.has(lan)) {
return translationCache.get(lan);
}
const data = await fetch(`/api/i18n/${lan}`).then(r => r.json());
translationCache.set(lan, data);
return data;
},
});
Testing
Unit Test Example
import { createI18n } from 'zustic/i18n';
describe('i18n', () => {
const mockTranslations = {
en: { greeting: 'Hello' },
es: { greeting: 'Hola' },
};
const useTestTranslation = createI18n({
initialLan: 'en',
resource: (lan) => mockTranslations[lan],
});
test('returns correct translation', async () => {
const { t } = useTestTranslation();
expect(t('greeting')).toBe('Hello');
});
test('switches language', async () => {
const { t, updateTranslation } = useTestTranslation();
updateTranslation('es');
// Wait for update
await new Promise(resolve => setTimeout(resolve, 100));
expect(t('greeting')).toBe('Hola');
});
});
React Component Test
import { render, screen } from '@testing-library/react';
import { TranslationProvider } from './TranslationProvider';
import { Header } from './Header';
test('renders translated header', () => {
render(
<TranslationProvider>
<Header />
</TranslationProvider>
);
expect(screen.getByText('Welcome')).toBeInTheDocument();
});
Comparison: i18n vs Alternatives
| Feature | Zustic i18n | i18next | react-i18next |
|---|---|---|---|
| Size | ~2KB | ~30KB | ~8KB |
| Type Safety | ✅ Full | ⚠️ Partial | ⚠️ Partial |
| Setup | ⭐ Very Simple | ⭐⭐⭐⭐ Complex | ⭐⭐⭐ Medium |
| Learning | ⭐ Easy | ⭐⭐⭐⭐ Steep | ⭐⭐⭐ Medium |
| Bundle | ⭐⭐⭐⭐⭐ Tiny | ⭐⭐ Large | ⭐⭐⭐ Medium |
| Async | ✅ Yes | ✅ Yes | ✅ Yes |
| React | ✅ Native | ⚠️ Via Plugin | ✅ Native |
Troubleshooting
Issue: Keys showing raw instead of translations
Problem: Seeing "common.welcome" instead of the actual translation
Solution: Wait for isInitialLoading to be false:
const { t, isInitialLoading } = useTranslation();
if (isInitialLoading) return <Spinner />;
return <h1>{t('common.welcome')}</h1>; // Now safe
Issue: Language not switching
Problem: updateTranslation doesn't change language
Solution: Ensure language type matches:
type Language = 'en' | 'es' | 'fr';
// ✅ Correct casting
updateTranslation(e.target.value as Language);
// ❌ Wrong - type mismatch
updateTranslation(e.target.value);
Issue: Slow translation loading
Problem: First language load takes too long
Solution: Implement caching:
const cache = new Map();
resource: async (lan) => {
if (cache.has(lan)) return cache.get(lan);
const data = await fetch(`/api/translations/${lan}`).then(r => r.json());
cache.set(lan, data);
return data;
}
Issue: Race condition on language switch
Problem: Switching languages rapidly causes stale translations
Solution: The library handles this with request IDs:
// This is automatic - last request wins
updateTranslation('es');
updateTranslation('fr');
updateTranslation('de');
// Result will be German, even if Spanish finishes last
Checklist Before Production
- ✅ All translation keys are type-safe
- ✅ Loading states are handled
- ✅ Language preferences are persisted
- ✅ All languages have complete translations
- ✅ Error handling is in place
- ✅ Performance is optimized (caching, lazy loading)
- ✅ Component tests pass
- ✅ i18n works in SSR if applicable
- ✅ Metadata/SEO is translated
- ✅ RTL languages are supported (if needed)
Next Steps
- Examples - See practical use cases
- Advanced Guide - Master advanced patterns
- API Reference - Detailed documentation