Status: accepted · 2026-09-20
i18next, FormatJS, Lingui and Polyglot all work in this framework already.
They are plain JavaScript, and nothing stops a component calling
t('greeting').
What none of them can do is tell the reactive graph that the answer changed:
const Header = component(() => <h1>{t('greeting')}</h1>);
// i18next.changeLanguage('de') — and the h1 still says "Hello"
A component runs once, so the call happens once. In a framework that re-renders, a language switch is a state change and everything runs again; here it has to be a signal, or it is nothing. That is not a bug in i18next, and it is not something i18next can fix.
The requirement was full compatibility with i18next or another common localisation library — not a translation library of our own.
t keeps its own types. A typed i18next resource table
gives key autocomplete and interpolation types, and a wrapper that erases
them would be a downgrade disguised as an integration.createInstance() or a test double must all work.<Trans>, or a context holding the
translator. It is the React shape, and it does not fit: context here is not
what makes something re-run, and a component per translated string is a lot
of machinery to re-read one function.Option 3. translator(source) takes three things — the library’s t, a way to
read the current language, and a way to subscribe — and returns a t that
subscribes whatever reads it.
const t = ((...args) => {
version.value; // reading it is the subscription
return source.translate(...args);
}) as T;
The cast is the point: the returned function has the parameter and return types of the one that was passed, because it is that function with a read in front of it.
fromI18next(instance) is that adapter for i18next, and it listens to four
events rather than one:
| Event | Announced on | Why it matters |
|---|---|---|
languageChanged |
the instance | the obvious one |
loaded |
the instance | a backend finished a namespace |
added |
the resource store | addResource at runtime |
removed |
the resource store | a resource taken away |
The last two were found by testing against the real library rather than against
something shaped like it. i18next announces a language change on the instance
and a resource change on the store, so listening only to the instance leaves
a key rendered as itself — nav.settings on the screen — forever after the
namespace it belongs to arrives.
The i18next shape is declared structurally (I18nextLike), so the package
imports nothing from i18next and works with anything that looks like it.
Two signals per translator, and one property read per t() call. A language
change writes both cells inside a batch, so a header that shows the locale
next to a translated title runs once rather than twice.
Nothing is added to the graph that was not already there: the cells are
ordinary signals, so tracking, the synchronous flush, disposal and untrack
behave exactly as they do everywhere else. There is no provider to walk and no
context lookup on the path to a translated string.
0.38 kB gzip, and only for an application that imports it.
One translator holds two cells and one subscription. Created inside a component
or a root, the subscription is disposed with that scope through onCleanup;
created at module level — which is the normal case — nothing owns it and it
lives as long as the page, with dispose returned for the cases that do not.
onCleanup is registered only when getOwner() is non-null, because at module
level it would warn about a callback that can never run: a warning that is
right in general and wrong here.
Setting up is two lines and the call sites do not change:
export const { t, language } = fromI18next(i18next);
Existing code that already calls t keeps working, with its types, and starts
updating.
<Trans> component or a provider (option 2). It imports a shape from
React that this framework does not need: what makes something re-run here is
reading a cell, not being underneath a provider.t in a new signature — t(key, values) of our own. It would
have cost the library’s types, which for a typed resource table is most of
what makes it pleasant to use.languageChanged. Smaller, and wrong in exactly the
case that is hardest to debug: a key that renders as itself and never
recovers.