# Handle missing keys and errors

Keep the app rendering when a key is missing or a catalog fails to load: fallback values, the fallback locale chain, onLoadError, onError and the events.

Canonical: https://comvi.io/docs/i18n/error-handling

Keep the app rendering when a key is missing or a catalog fails to load. `t()` never throws
on a missing key, and every failure reaches a hook you can wire to your error tracker. Every
hook is a constructor option or an event on the plain `@comvi/core` instance from the
[Quick start](/docs/i18n/quick-start), so no binding is required.

## Missing keys

`src/i18n.ts`

```ts

export const i18n = createI18n({
locale: 'de-AT',
fallbackLocale: ['de', 'en'],
onMissingKey: ({ key }) => (import.meta.env.DEV ? `[MISSING: ${key}]` : ''),
});
```

A key missing or empty in `de-AT` is looked up in `de`, then `en`, and only then reported. Set your
most complete locale as `fallbackLocale`. When every locale misses, `t()` emits a
`missingKey` event and returns whatever `onMissingKey` returns, or the key itself when the
option is not set. A `fallback` passed to `t()` outranks both.

## Load errors

`src/components/TranslatedSection.tsx`

```tsx

export function TranslatedSection() {
const { t } = useI18n();
const { onLoadError } = useI18nLoader();
const [loadFailed, setLoadFailed] = useState(false);

useEffect(() => {
    const unsubscribe = onLoadError(() => setLoadFailed(true));
    return () => unsubscribe();
}, [onLoadError]);

if (loadFailed) return <div className="i18n-error">Some translations failed to load.</div>;
return <h1>{t('section.title')}</h1>;
}
```

`onLoadError` fires once per `locale:namespace` pair a loader failed to load. In a
component it comes from `useI18nLoader()`, and on the instance from `i18n.onLoadError()`; both
return an unsubscribe function. `useI18nLoader()` throws on an instance without the loader
capability, in development and production alike. `init()`, `setLocale()`,
`addActiveNamespace()` and `reloadMessages()` also reject their promise when every
requested load fails, so `await` them where you want to handle that at the call site.

## Report everything with onError

`src/i18n.ts`

```ts

declare const Sentry: {
captureException(error: unknown, hint?: Record<string, unknown>): void;
addBreadcrumb(breadcrumb: Record<string, unknown>): void;
};

export const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
onError: (error, context) => {
    Sentry.captureException(error, {
      tags: { subsystem: 'i18n', source: context?.source },
      extra: context,
      level: context?.source === 'namespace-load' ? 'warning' : 'error',
    });
},
}).use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));

i18n.on('missingKey', ({ key, locale, namespace }) => {
Sentry.addBreadcrumb({ category: 'i18n', message: `Missing: ${key}`, data: { locale, namespace } });
});
```

`onError` receives every error the instance reports, with a `context.source` naming where it
came from: `plugin`, `plugin-cleanup`, `request-cleanup`, `init`, `translation`,
`namespace-load`, `post-processor`, `event` or `compile`; the bindings add `setLocale` for a
switch they started. The context also carries `pluginName`,
`tagName`, `key`, `locale`, `namespace` and `event` when they apply. A throw inside
`onError` or an event listener is swallowed, so a buggy handler cannot crash rendering.
Report missing keys as breadcrumbs rather than errors: they are common in development.

## Events

`src/events.ts`

```ts

const off = i18n.on('loadError', ({ locale, namespace, error }) => {
console.error(`Failed to load ${locale}:${namespace}`, error);
});

off();
```

| Event | Fires when | Payload |
| --- | --- | --- |
| `initialized` | `init()` completed | `void` |
| `destroyed` | `destroy()` was called | `void` |
| `localeChanged` | the active locale changed | `{ from, to }` |
| `defaultNamespaceChanged` | the default namespace changed | `{ from, to }` |
| `messagesCleared` | the cache was cleared | `{ locale?, namespace? }` |
| `loadingStateChanged` | loading state changed | `{ isLoading, isInitializing }` |
| `namespaceLoaded` | a catalog landed, from a loader or `addMessages()` | `{ namespace, locale }` |
| `missingKey` | `t()` found text for a key in no locale | `{ key, locale, namespace }` |
| `loadError` | a loader failed for one pair | `{ locale, namespace, error }` |
| `configChanged` | a config setter ran | `{ source }` |

`configChanged.source` is `'fallbackLocale'`, `'defaultParams'`, `'namespaceActivated'` or
`'namespaceDeactivated'`.
The synchronous `i18n.locale =` setter reports a failed load as a `loadError` with the
namespace `"locale-change"`, so prefer `await setLocale()` where the failure matters.

## Fail loudly in tests

`src/i18n.test-setup.ts`

```ts

export const i18nErrors: Array<{ error: Error; source?: string }> = [];

export const i18n = createI18n({
locale: 'en',
strict: 'dev',
onError: (error, context) => {
    i18nErrors.push({ error, source: context?.source });
},
}).use(plugins());

i18n.onMissingKey((key, locale, namespace) => {
throw new Error(`Missing key "${key}" in [${locale}/${namespace}]`);
});
```

`src/i18n.test.ts`

```ts

afterEach(() => {
expect(i18nErrors, 'i18n recorded errors during this test').toHaveLength(0);
i18nErrors.length = 0;
});
```

Runtime `onMissingKey` callbacks and the `onMissingKey` option run synchronously inside
`t()` with no `try/catch`, so a throw there fails the test. `strict: 'dev'` adds development
diagnostics but does not throw by itself. A load or plugin error is reported before anything
rejects, and most never reject: a partial load and an optional plugin are reported only, while
an all-namespaces failure and a `required` plugin reject the call after the report. Collect them
in `onError` and assert the buffer is empty after each test. Runtime
`i18n.onMissingKey()` lives on the plugin capability, hence `.use(plugins())`; the
constructor option needs nothing composed.

> **A plugin that must not block startup**
>
> A plugin that throws or times out during `init()` aborts it. Register optional plugins with
> `{ required: false }` so the app starts without them. See
> [Registration options](/docs/i18n/plugins#registration-options).

## How it works

The instance has two separate channels, and which one a failure takes decides what the user
sees. A missing key is not an error. It is the normal end of a lookup, handled inside `t()`:
the fallback chain is walked, `missingKey` is emitted, the runtime callbacks run, and the first
of `params.fallback`, a callback's result, the `onMissingKey` option's result or the key itself
is returned, in that order, synchronously. Nothing in that path reaches `onError`, so a tracker
wired to `onError` alone never hears about a missing key; the `missingKey` event is where that
report comes from. The user always sees a string, and the only question is which one.

Everything else is an error, and `onError` is the single sink for it. `init()` reports with
`source: 'init'` and rethrows. A namespace that fails to load reports with
`source: 'namespace-load'` while the other namespaces still land, and the operation rejects only
when every requested namespace failed. An event listener that throws is reported and swallowed,
so one bad subscriber cannot take down a render. A plugin that throws or times out during
`init()` is reported, and aborts `init()` only when it was registered as required. In
production, an ICU message the compiler cannot read reports `E_ICU_SYNTAX` once per compilation
and renders its braces literally. None of these reach `t()`: a failed load leaves the namespace
empty, so until a retry its keys walk the fallback chain and then the missing-key path, and the
user sees the fallback locale's text or the key. The failed pair is dropped from the in-flight
set, so the next locale switch or `reloadMessages()` calls the loader again.

`loadError` is the event form of the same failure. It fires once per `locale:namespace` pair,
on the instance through `i18n.on('loadError')` or `onLoadError()`, and in a component through
`useI18nLoader()`. The two channels carry the same error: `onError` is where a tracker
subscribes once, `loadError` is where a component decides what to render.

Development and production differ in the text, not the path. Every message is built as
`IS_DEV ? 'long text' : 'E_CODE'` and the bundler picks the build: the `development` export
condition serves the long texts, the default build serves the codes. Bundlers set that
condition in development mode, so the same `catch` sees
`[@comvi/core] registerLoader: no entry for "fr:default"` on your machine and
`E_REGISTER_LOADER_ENTRY` in production. Match on the code. Only `E_ICU_SYNTAX`,
`E_USE_INSTANCE_IDENTITY` and `E_USE_NOT_INSTALLER` carry a `code` property; for the rest the code
is the message. `strict: 'dev'` adds one more development-only line, `[@comvi/core] Translation not
found: "key"`, per miss; it never throws, and a production build logs nothing for a miss, because
the warning call compiles out of that build. Every code, with cause and fix, is on [Errors](/docs/i18n/errors).

## Read next

- [Errors](/docs/i18n/errors): every error code with its development message, cause and fix.
- [Load translations on demand](/docs/i18n/loading-translations): loaders, fallback maps and namespaces.
- [Test components that translate](/docs/i18n/guides/testing): a real instance per test, with strict missing keys.
