JSON translations with ICU plurals that update through signals. Signals, alien_signals or solidart: one API for all three.
signals_translator 1.0 is out. It's a small, JSON based i18n library for Flutter where every translated string is a signal. Change the language and only the widgets that read a translation rebuild. Nothing else.
It started as me scratching my own itch. I used easy_localization before, and it's a great package, but two things bothered me. First, it made my whole app re-render on a locale change. Second, it was yet another wrapper. At that point my app was wrapped in AdaptiveTheme, then MaterialApp, then EasyLocalization, and the widgets inside still needed a Watch to react to my signals. I disliked that every plugin that needed injecting could only get there by adding yet another wrapper. I also no longer knew which order they were supposed to go in.
I'm a signals enthusiast, so I wanted translations to behave like the rest of my state: fine grained and reactive, with no extra wrapper. signals_translator adds nothing to your widget tree. It only uses the SignalBuilder (formerly Watch) you already have.
After using signals in several applications, and copying the logic between projects, I decided to publish a plugin that I could use myself, so I would have to maintain less code. Over the months, the project matured (although it's still pretty basic). After adding ICU instead of my self-invented conventions, I released 0.1.0 just last week.
When I dug into state management for Flutter again, I realized there are more packages using the signal mental model. They're all alike; they just have different names. I decided to support these libraries as well.
So, what began as one package for the signals library is now a family of three, one each for solidart, signals and alien_signals, all on one shared engine and one version number. Same API, same JSON files, same ICU plurals. You pick the reactive framework; the translator follows.
Why three packages instead of one
My first instinct was one package that supports every signal library and lets tree shaking remove what you don't use. That doesn't work in Dart. Tree shaking trims unused code from the compiled output, but pub still resolves every dependency in your pubspec. Ship one package that depends on signals, alien_signals and solidart, and every user downloads, resolves and version constrains all three, even if they only ever touch one.
So 1.0 is split the boring, correct way:
- signals_translator_core: the shared engine, no reactivity of its own
- solidart_translator: for solidart, rebuilds with SignalBuilder from flutter_solidart
- signals_translator: for signals, rebuilds with SignalBuilder from signals_flutter
- alien_signals_translator: for alien_signals, rebuilds with SignalBuilder from oref
The core holds everything that isn't reactivity: JSON loading, locale resolution, the fallback chain, persistence and the ICU parser. Each adapter is a thin layer that wraps that engine in its framework's signals. You add exactly one adapter, and your dependency graph contains exactly one reactive library.
A nice side effect: all packages share one version, so a fix in the ICU parser lands in all three frameworks on the same day.
Getting started
Create assets/translations/ with one JSON file per language, named by locale code:
// assets/translations/en.json
{
"language": "English",
"translations": {
"greeting": "Hello!",
"{0} has won the game!": "{0} has won the game!"
}
}
Add your adapter and register the assets:
dependencies:
signals_translator: ^1.0.0 # or alien_signals_translator / solidart_translator
flutter:
assets:
- assets/translations/
Read translations inside a SignalBuilder. With signals:
import 'package:signals/signals_flutter.dart';
import 'package:signals_translator/signals_translator.dart';
SignalBuilder(
builder: (context) => Text(tl('greeting')),
);
With alien_signals, the rebuild widget comes from oref:
import 'package:alien_signals_translator/alien_signals_translator.dart';
import 'package:oref/oref.dart' show SignalBuilder;
SignalBuilder(
builder: (context) => Text(tl('greeting')),
);
With solidart, it comes from flutter_solidart, whose builder takes a second argument:
import 'package:flutter_solidart/flutter_solidart.dart';
import 'package:solidart_translator/solidart_translator.dart';
SignalBuilder(
builder: (context, _) => Text(tl('greeting')),
);
Switching language is one call, and every SignalBuilder reading a translation updates:
SignalTranslator().loadLocale('nl');
SignalTranslator().currentLocale; // 'nl', or 'sys' for system language
SignalTranslator().resolvedLocale; // concrete code for intl's DateFormat
No locale stored yet? The app starts in the device language, and the user's choice is persisted for the next launch.
What you get in 1.0
Three functions, that's the API. tl for plain strings, tlv for one variable, tlvm for several:
Text(tl('greeting'));
Text(tlv('{0} has won the game!', 'David'));
Text(tlvm('He came in {0}, his partner came in {1}', ['first', 'second']));
ICU message format, inline. Plurals, ordinals and select live inside the translation string itself, so translators control the whole sentence, verb included:
// en.json
"winner_announcement": "There {0, plural, one {is # winner} other {are # winners}}!"
// nl.json
"winner_announcement": "Er {0, plural, one {is # winnaar} other {zijn # winnaars}}!"
Text(tlv('winner_announcement', '1')); // There is 1 winner!
Text(tlv('winner_announcement', '5')); // There are 5 winners!
Plural categories follow CLDR rules for the active locale via package:intl, so Polish gets few/many and Arabic gets all six. Exact matches like =0, offset:, multiple blocks per string and nesting all work. Select handles gender or any category:
"reaction": "{0, select, female {She} male {He} other {They}} liked your post."
A malformed block never crashes your app. It renders verbatim and prints a debug warning naming the key.
Regional locales with a fallback chain. Ship en.json, then add en_GB.json when you need it. A missing regional file falls back to the bare language, then to your fallbackLocale:
assets/translations/
en.json
en_GB.json
nl.json
SignalTranslator().loadLocale('en_GB'); // 'en-gb' and 'EN_gB' work too
Shared translations across apps. In a monorepo, put your JSON files in one package and point every app at it:
SignalTranslator().translationsPath =
'packages/my_translations/assets/translations';
Coming from 0.x? tlp and tlpm are gone; nested zero/one/other objects become ICU strings. The migration section in the README shows the before and after.
What it doesn't do
No date formatting (use intl with resolvedLocale), no code generation, no AI features, and JSON is the only file format. It is a small package, but I am open to suggestions.
If you're already using signals, alien_signals or solidart for your state, your translations can now work the same way. Try it, break it, and open an issue or a PR. I'd especially love feedback from people shipping in languages with complex plural rules.
Top comments (0)