Offline & Caching
Sur cette page
- Defense in Depth
- Recommended Setup
- Keeping staticData in Sync
- Cache Layers
- 1. Resource Store (In-Memory)
- 2. Persistent Storage
- 3. Static Data (Bundled Translations)
- Persistent Storage
- storageAdapter() (Recommended)
- Manual Implementation
- Locale Persistence
- Without localeKey
- With localeKey
- SDK Warnings
- Locale Detection Patterns
- Pattern 1: SDK-managed (recommended)
- Pattern 2: App-managed
Mobile apps need to work without network. @better-i18n/expo uses a network-first strategy with persistent storage so your app always gets fresh translations — and never crashes when the CDN is unavailable.
Defense in Depth #
Production mobile apps should have three layers of translation fallback. If any layer fails, the next one catches it.
initBetterI18n() / changeLanguage()
│
├─ 1. CDN fetch (network-first)
│ ├─ Success → Use fresh translations + update cache
│ └─ Failure ↓
│
├─ 2. Persistent storage (MMKV / AsyncStorage)
│ ├─ Cached? → Use cached translations
│ └─ No cache ↓
│
└─ 3. staticData (bundled in app binary)
├─ Has locale? → Use bundled translations
└─ No data → Raw translation keys shown ⚠️| Layer | Protects against | Required? |
|---|---|---|
| CDN | Nothing — this is the happy path | Always active |
| Persistent storage | App restarts, intermittent network issues | Strongly recommended |
| staticData | First launch with no network (App Store Review, airplane mode) | Strongly recommended for production |
Recommended Setup #
This is the production-ready pattern. All three layers active:
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { MMKV } from 'react-native-mmkv';
import { initBetterI18n, storageAdapter } from '@better-i18n/expo';
// Bundled translations — generated by: npx @better-i18n/cli pull
import en from './locales/en.json'; // [!code highlight]
import tr from './locales/tr.json'; // [!code highlight]
const mmkv = new MMKV({ id: 'i18n' });
i18n.use(initReactI18next);
export const i18nReady = initBetterI18n({
projectId: 'acme/my-app',
i18n,
storage: storageAdapter(mmkv, { localeKey: '@app:locale' }), // [!code highlight] Layer 2
staticData: { en, tr }, // [!code highlight] Layer 3
useDeviceLocale: true,
});Keeping staticData in Sync #
Use the CLI to download fresh translations before each build:
npx @better-i18n/cli pull -o ./localesThis fetches all languages from your CDN and writes them to locales/*.json. Add it to your CI/CD pipeline:
#!/bin/bash
npx @better-i18n/cli pullCache Layers #
1. Resource Store (In-Memory) #
The fastest layer. Once translations are loaded into i18next's resource store, subsequent changeLanguage() calls skip the CDN entirely.
- Survives navigation and re-renders
- Cleared when the app is killed
- Checked via
hasResourceBundle()before any network call
2. Persistent Storage #
Translations are persisted to device storage so they survive app restarts. Used as offline fallback when the CDN is unreachable.
Storage keys (per locale):
@better-i18n:{project}:{locale}:translations → JSON translation data
@better-i18n:{project}:{locale}:meta → { cachedAt: timestamp }Each locale has a single cache entry that gets overwritten on every successful CDN fetch.
3. Static Data (Bundled Translations) #
Translations bundled directly in your app binary via staticData. Always available — no network or storage needed. Used as the last resort when both CDN and persistent cache fail.
Because they're shipped with the app, they may go stale between releases. Run better-i18n pull before each build to keep them current.
Persistent Storage #
storageAdapter() (Recommended) #
storageAdapter() wraps MMKV or AsyncStorage automatically.
import { MMKV } from 'react-native-mmkv';
import { initBetterI18n, storageAdapter } from '@better-i18n/expo';
const mmkv = new MMKV({ id: 'app' });
await initBetterI18n({
projectId: 'acme/app',
i18n,
storage: storageAdapter(mmkv, { localeKey: '@app:locale' }),
});Manual Implementation #
For a fully custom store, implement the TranslationStorage interface directly:
await initBetterI18n({
projectId: 'acme/app',
i18n,
storage: {
getItem: async (key: string) => /* ... */,
setItem: async (key: string, value: string) => /* ... */,
removeItem: async (key: string) => /* ... */,
},
});Locale Persistence #
By default, locale selection is not persisted across app restarts. Pass localeKey to storageAdapter() to enable automatic locale persistence.
Without localeKey #
The user's language selection is lost when the app is killed. On next launch, the startup locale is resolved in this order:
Startup locale resolution (no localeKey):
1. getDeviceLocale() ← if useDeviceLocale: true
2. defaultLocale ← fallbackWith localeKey #
When localeKey is set, storageAdapter() returns a LocaleAwareTranslationStorage that reads and writes the active locale alongside translation data. On startup:
Startup locale resolution (with localeKey):
1. storage.readLocale() ← user's saved preference (wins if set)
2. getDeviceLocale() ← if useDeviceLocale: true
3. defaultLocale ← fallbackinitBetterI18n detects the readLocale / writeLocale methods via duck-type check at runtime — no extra configuration required.
SDK Warnings #
The SDK automatically detects common misconfigurations and logs warnings:
| Condition | Warning |
|---|---|
No storage provided | "Translations won't survive app restarts" |
No staticData provided | "App will show raw translation keys if CDN is unreachable on first launch" |
Neither storage nor staticData | CRITICAL: "Zero offline fallback — will cause App Store rejection" |
staticData has empty objects | "Bundled fallback will not work for these locales" |
staticData missing namespaces vs CDN | "Missing N namespaces — won't be available offline" |
useDeviceLocale: true without localeKey | "User's in-app language choice will be lost on every restart" |
These warnings appear in your development console to help you catch issues before submitting to the App Store.
Locale Detection Patterns #
There are two ways to handle initial locale detection. Choose one:
Pattern 1: SDK-managed (recommended) #
The SDK detects the device language on first launch, then persists the user's choice via localeKey.
await initBetterI18n({
projectId: 'acme/app',
i18n,
useDeviceLocale: true, // [!code highlight]
storage: storageAdapter(mmkv, { localeKey: '@app:locale' }), // [!code highlight]
});On first launch: device language is used. After the user changes language in-app, that choice is saved and used on subsequent launches.
Pattern 2: App-managed #
Your app handles locale detection and persistence. Pass the resolved locale as defaultLocale.
const locale = myStorage.getLanguage() || getDeviceLanguage() || 'en';
await initBetterI18n({
projectId: 'acme/app',
i18n,
useDeviceLocale: false, // [!code highlight]
defaultLocale: locale, // [!code highlight]
storage: storageAdapter(mmkv),
});
Better I18N