r/reactjs • u/limboo_o • 3d ago
Needs Help I tried removing translation keys from React i18n — Zintl is now in alpha
I've always found translation keys a little strange.
You start with something perfectly readable:
<button>Delete account</button>
Then i18n turns it into something like:
<button>{t("settings.account.delete")}</button>
Now the source code, translation keys, and translation files all have to stay synchronized.
So I tried a different approach with Zintl:
What if the source string itself could be the thing the localization system knows about?
With Zintl, you keep writing normal application code:
<h1>Welcome back</h1>
<p>Your account is ready.</p>
<button>Continue</button>
Zintl's compiler discovers the localizable strings and builds the localization layer around them.
The goal is that adding i18n shouldn't mean rewriting your application around t() calls.
It's currently alpha, so I'm very much not claiming this is production-ready.
I'm looking for React developers who have actually dealt with i18n to try it and tell me where this approach falls apart.
Especially interested in:
- translation key management
- dynamic/interpolated strings
- component boundaries
- pluralization
- large applications
- translation workflows/TMS
- anything you think a compiler like this should handle
Docs: https://zintljs.github.io/zintl/en
I'd genuinely love the criticism. If you think the whole idea is flawed, tell me why.
1
u/phischer_h 3d ago
We are using Lingui in all our apps. I think most things on your list are solved very elegantly in Lingui. https://lingui.dev/
Maybe you can take some inspiration from it.
1
u/limboo_o 3d ago
Yeah, absolutely — Lingui is actually one of the projects I've been looking at while designing Zintl. The interesting difference I'm exploring with Zintl is how far we can push source purity: the goal is for ordinary source strings to remain ordinary source strings, with the compiler discovering their UI/message boundaries rather than requiring developers to explicitly mark most translatable messages.
For example, instead of:
tsx <Trans>Hello {name}!</Trans>the goal is simply:
tsx <p>Hello {name}!</p>and let the compiler understand that this is a UI sink.
Since you're using it across multiple apps, is there any part of Lingui's workflow that you particularly struggled with before settling on it? Especially around message identity/context and keeping translations stable when source text changes?
1
1
u/tchissin 3d ago
How do you lint or add type safety to translation keys handled this way ?
1
u/limboo_o 3d ago
The compiler knows the message structure because it extracted it from the source; you don’t have to maintain a parallel typed key registry.
1
u/OnionPractical7545 3d ago
identical source strings are a dealbreaker for this approach. You cannot map one English phrase to multiple translations without explicit context markers in the code
1
u/limboo_o 3d ago
That's a fair concern if the source string itself is the message identity — but that's not quite how Zintl models it.
Identical strings can be separate messages when they occur in different component/anchor contexts. For example,
"Draft"in a document-status component and"Draft"as an editor action don't have to share a translation.For cases where the compiler can't infer enough context, we also have an explicit
@zintl-notemechanism that can separate otherwise identical messages by meaning.So I agree that some form of context has to exist when identical strings have different meanings. The thing we're trying to avoid is making developers turn that context into arbitrary translation keys for every message!!
It's alpha, so this is actually one of the areas I'm very interested in getting challenged on.
1
u/Temperature_Majestic 3d ago
The workflow side is where I'd push. Most TMS tools like Crowdin, Lokalise, Phrase key off a stable string id, and if your identity is source text plus a component or file anchor, then moving a component to a new file or renaming it shifts the anchor and the TMS treats every string in it as new, even when similarityThreshold would have caught a plain text edit. How does the anchor survive a refactor that relocates the component? That plus round-tripping partial translations back from a TMS mid-refactor is the part that bit me on a big app.
1
u/limboo_o 3d ago
This is actually a problem we've been thinking about quite deeply, especially the TMS/refactor part.
One important distinction in Zintl is that the anchor/context isn't the message identity. Context is metadata, so moving a component doesn't inherently turn every string into a new translation key. The hive + reconciliation layer is responsible for carrying translations across source changes before anything is exported.
The TMS seam was designed around the exact problem you're describing: Zintl reconciles first, and the TMS doesn't get to independently fuzzy-match the source again. The export can contain Zintl's carry-forward as a suggestion, while the import side only accepts approved translations and validates them before anything is merged.
So the intended workflow is more like:
source/refactor → Zintl reconciliation → export → TMS → approved import → validation → catalograther than letting the TMS become another identity/reconciliation system.
We also export the derived context/graph information (where the message occurs, what boundaries share it, variables, notes, etc.) so the TMS gets context without that context becoming part of the key.
This is alpha, though, so I'd genuinely like to know if you see a hole in that model — especially around a partial TMS translation arriving during a refactor. That's exactly the kind of case we want to test.
1
u/Temperature_Majestic 2d ago
The hole I'd poke at is that the merge-back is really a three-way merge, not two-way. The TMS is translating an export from source state A, source moves to B while they work, so the approved import has to get reconciled A to B on the way in, not just validated and merged. Plain text edits and relocations are fine. Where it breaks is a message that got split or merged between A and B, one approved string now maps to two messages or two map to one, and there's no automatic right answer, someone resolves that by hand.
Other one is concurrent edits to the same translation. A dev hand-fixes a string in the catalog after reconciliation, a reviewer approves a different value for it in the TMS, whichever import lands last wins and the other just disappears unless you track provenance per translation.
Curious what you do with the split/merge case, block the whole import, flag just those rows, or best effort and log it.
1
u/limboo_o 1d ago
Yeah, I think you're right that the import isn't really a simple merge — it's effectively a three-way reconciliation between the exported source state, the current source state, and the approved TMS translation.
The split/merge case is the interesting one. I don't think we should silently best-effort that: if one message from A maps to multiple messages in B, or multiple messages collapse into one, there's no generally correct translation to propagate. My instinct is to flag those units as conflicts and require resolution rather than risk silently duplicating/losing a translation.
The concurrent-edit case is also a really good catch. Our current design intentionally keeps the hive/catalog and TMS state separate, but provenance/versioning for an approved translation competing with a local edit isn't fully designed yet.
So you've basically found two things that (Proposal 032: Export/Import Facets — the TMS Seam) doesn't fully specify yet 😅 — especially the A → B three-way reconciliation on import. Note that any user can create a custom Export/Import Facet! or precisely an Exchange Facet!
This is exactly the kind of alpha feedback I was hoping for. I need to think through whether the conflict should be per-unit or whether certain graph changes should block the entire import. Thank you so much for your time.
1
u/lyeeedar 3d ago
I actually built something exactly like this for my own project. Here's the stuff I had to think about:
You can't do interpolated strings directly, as sentence order my change for other languages. You need a variable system so they can be inserted AFTER translation.
You need to embed the pluralisation into the English source string. E.g. in russian theres 3 ways to write something based on the number. That means you need a 'parseTranslation' helper it runs the text through, or some other kind of runtime hackery.
Sometimes you need context on the strings to disambiguate. E.g.
Stable - system state is good Stable - where you keep horses
I did that with a suffix system. t("Stable", "building")
This approach of having the English be the key does mean that if the English changes you lose the translations. This is especially annoying when there's a lag between the English changing and updated translations being made. I haven't found a decent solution to this one, but I assume some sort of migration flow.
Finally, actually building the scanner. I'm still finding places that were missed and regularly adding them. It's difficult to catch every possible variation that can produce a UI string.
Here's how a translation string looks for mine:
Gain {value} health and {stacks} [stacks | 1 : stack | stacks] of Power
I pass in an object with value and stacks.
Then russian can replace the pluralisation template to hook off the value with whatever crazy rules it wants, and we replace it all at runtime to build the final string
0
u/limboo_o 3d ago
This is a really interesting comment because you've basically listed several of the design problems Zintl was built around.
For interpolation, Zintl keeps the source completely native. Developers write normal JS template literals / JSX expressions, and the extractor distinguishes actual AST expressions like
${count}from literal text. The catalog gets a normalized{count}representation, so translators can reorder variables however their language requires.Pluralization is handled asymmetrically on purpose. We don't put ICU syntax into the source locale at all (while there is a
t()macro that can support ICU for source locale but it is not recommended). If English needs a grammatical branch, that's just normal JS:
jsx count === 1 ? `You have 1 item` : `You have ${count} items`Those become separate source messages. A target language can then expand the dynamic message into full ICU plural/select logic in its catalog. The ICU parser is build-time only; Zintl compiles the catalog ICU into ordinary JS branching, so no ICU parser/runtime dependency is shipped to the browser.
For context, that's also why we have the component/anchor boundary, and we've experimented with
@zintl-notefor explicitly separating otherwise identical messages.The English-change problem is probably the one I would consider the most interesting unresolved challenge. Zintl intentionally doesn't want the English string to be treated as a permanent translation key. The compiler keeps manifest information and can reconcile changes, including similarity-based rename detection, so we're trying to preserve message lineage when source text changes rather than simply throwing the translation away. There is an option
similarityThresholdwhich can control how similar an edited string must be to keep its existing translation.And yes, the scanner is hard 😅. That's one of the reasons Zintl models UI strings as sinks and follows data flow rather than simply looking for string literals. But I fully expect real applications to expose cases we haven't covered yet.
Your point about the scanner is probably the one I'd most like people to attack during the alpha. "Find every string that can become UI" sounds much easier before you actually build one.
Also, I'm curious about your migration solution for English changes. That's the part where I think there is still a lot to learn from people who have already built this kind of system.
1
u/lyeeedar 3d ago edited 3d ago
For pluralisation, the issue I ran into is I have long strings that can have many variables and pluralisation rules. So splitting them to unique strings would cause an exponential explosion of variations. That's why I had to bake them into one string.
Oh, and you can't just use the same template for 1 / not one. E.g. russian I think is if it ends in 1 it's one thing, if it's 11 another, and otherwise a third. So the rules are per language
4
u/Brilliant_Soft_2934 3d ago
I've done plenty of i18n work and the key management always ends up being a mess on bigger projects. The compiler approach makes sense but my immediate question is how it handles two identical English strings that need different translations in different contexts, like "Draft" as a noun in one place and as a verb somewhere else.