Translations (i18n)
i18next looks up translated strings, fills in values and picks plural forms. In a Sygnal app the chosen locale is ordinary state: the root keeps it, .context hands every component a t() for that locale, and persist() remembers it for the next visit. Switching the language is an action like any other, and every component re-renders in the new language.
Install
Section titled “Install”npm install i18nextThe i18next instance
Section titled “The i18next instance”import i18next from 'i18next'
export const LOCALES = { en: 'English', fr: 'Français' }
export const i18n = i18next.createInstance()i18n.init({ initAsync: false, // the resources are here: ready before the first render interpolation: { escapeValue: false }, // Sygnal renders strings as text, never as HTML fallbackLng: 'en', supportedLngs: Object.keys(LOCALES), resources: { en: { translation: { language: 'Language', greeting: 'Hello, {{name}}!', cart: { title: 'Your cart', add: 'Add a coffee', items_one: '{{count}} item', items_other: '{{count}} items', total: 'Total: {{total, currency(EUR)}}', }, }, }, fr: { translation: { language: 'Langue', greeting: 'Bonjour, {{name}} !', cart: { title: 'Votre panier', add: 'Ajouter un café', items_one: '{{count}} article', items_other: '{{count}} articles', total: 'Total : {{total, currency(EUR)}}', }, }, }, },})
// One t() per locale, made once: the context value changes only when the locale doesconst fixed = {}export const translator = (locale) => (fixed[locale] ||= i18n.getFixedT(locale))
// The browser's language; 'en' on the server (Node 21+ has a navigator too, with the server's)export function detectLocale() { const lang = typeof window === 'undefined' ? 'en' : (navigator.language || '').slice(0, 2) return lang in LOCALES ? lang : 'en'}initAsync: falsewith the translations inline makest()work as soon as the module is loaded, so the first render is already translated.escapeValue: false: i18next escapes the values it fills in for HTML by default (&becomes&). Sygnal puts strings into the page as text, which is already safe, so escaping would show the entities.getFixedT(locale)returns atbound to one locale. The app never callsi18n.changeLanguage(): the locale lives in Sygnal’s state, not in the i18next instance, so several apps (or tests) can show different languages at once.translatorkeeps onetper locale, so the function only changes when the locale does.
The app
Section titled “The app”import { persist } from 'sygnal'import { LOCALES, translator, detectLocale } from './i18n.js'import { Cart } from './Cart.jsx'
export function App({ state }) { const t = translator(state.locale) return ( <main lang={state.locale}> <label> {t('language')} <select className="locale" value={state.locale}> {Object.entries(LOCALES).map(([code, name]) => <option value={code}>{name}</option>)} </select> </label> <h1>{t('greeting', { name: state.name })}</h1> <Cart state="cart" /> </main> )}
App.initialState = { locale: detectLocale(), name: 'Ada', cart: { items: 0 } }
App.context = { t: (state) => translator(state.locale) }
App.intent = ({ DOM }) => ({ LOCALE: DOM.change('.locale').value(),})
App.model = { LOCALE: (state, locale) => ({ ...state, locale }),}
App.persist = persist({ key: 'locale', pick: ['locale'] })const PRICE = 4.5
export function Cart({ state, context }) { const { t } = context return ( <section> <h2>{t('cart.title')}</h2> <p className="count">{t('cart.items', { count: state.items })}</p> <p className="total">{t('cart.total', { total: state.items * PRICE })}</p> <button className="add">{t('cart.add')}</button> </section> )}
Cart.intent = ({ DOM }) => ({ ADD: DOM.click('.add'),})
Cart.model = { ADD: (state) => ({ ...state, items: state.items + 1 }),}- The locale is a key of the root’s state. On the first visit it comes from the browser (
detectLocale()); after that,persist()restores the saved one before the first render, so there is no flash of the wrong language. tfor every component:App.context.tis computed from the root’s state, and every component below reads it ascontext.t, however deep. When the locale changes, the context changes, and the components that use it render again. The root itself callstranslator(state.locale), since the locale is in its own state.- Reducers get the context too, on their fourth argument:
(state, data, next, { context }) => ... context.t('saved') ..., for a message the model builds.
Plurals, values and formats
Section titled “Plurals, values and formats”- Plurals: pass
count, and i18next picks the key with the language’s plural suffix (items_one,items_other; some languages have_zero,_two,_few,_many). The rules are the language’s own, fromIntl.PluralRules: French uses the singular for 0 (“0 article”), English the plural (“0 items”). - Values:
{{name}}in a string is filled from the options:t('greeting', { name: state.name }). - Formats:
{{total, currency(EUR)}}formats withIntl.NumberFormatfor the locale: “€4.50” in English, “4,50 €” in French.number,datetime,relativetimeandlistwork the same way.
Accessibility
Section titled “Accessibility”- Put the locale in a
langattribute (here on<main>), so screen readers pronounce the text in the right language. For the whole page, setdocument.documentElement.langfrom anEFFECTin theLOCALEentry and inBOOTSTRAP. - Translated labels are fine. sygnal-check’s accessibility rules (SYG702, SYG705) check that a field has a label and a button has a name; a
{t('...')}expression counts as one. They can’t check what the translation says, so keep the keys for labels and names present in every language (a missing key shows the key itself). - Translate
aria-labels,alttext andtitles too:aria-label={t('cart.remove')}.
Testing
Section titled “Testing”renderComponent gives a persisting root a fake storage; fill it to start in a saved locale, and read what was saved with t.storage():
import { test, expect } from 'vitest'import { renderComponent } from 'sygnal'import { App } from './App.jsx'
const text = (t, selector) => t.query(selector).textContent.replace(/\s/g, ' ')
test('restores the saved locale, pluralises, switches language and saves it', async () => { const t = renderComponent(App, { storage: { locale: { version: 1, state: { locale: 'fr' } } } }) await t.ready() expect(text(t, 'h1')).toBe('Bonjour, Ada !') expect(text(t, '.count')).toBe('0 article')
t.simulateEvent('.add', 'click') t.simulateEvent('.add', 'click') await t.next((state) => state.cart.items === 2) expect(text(t, '.count')).toBe('2 articles') expect(text(t, '.total')).toBe('Total : 9,00 €')
t.simulateEvent('.locale', 'change', { value: 'en' }) await t.next((state) => state.locale === 'en') expect(text(t, 'h2')).toBe('Your cart') expect(text(t, '.count')).toBe('2 items') expect(text(t, '.total')).toBe('Total: €9.00') expect(t.query('main').getAttribute('lang')).toBe('en')
await t.settle() expect(t.storage('locale')).toEqual({ version: 1, state: { locale: 'en' } }) t.dispose()})Intl puts non-breaking spaces in French numbers and punctuation (“9,00 €”, “Ada !”); the text helper turns them into plain spaces before comparing.
A component further down reads t from the context, which comes from its ancestors. To test it alone, pass its state and the context its ancestors would give it (renderComponent’s context):
import { test, expect } from 'vitest'import { renderComponent } from 'sygnal'import { Cart } from './Cart.jsx'import { translator } from './i18n.js'
test('the cart in French', async () => { // the state and the context the app would give it const t = renderComponent(Cart, { initialState: { items: 1 }, context: { t: translator('fr') } }) await t.ready() expect(t.query('.count').textContent).toBe('1 article') t.simulateEvent('.add', 'click') await t.next((state) => state.items === 2) expect(t.query('.count').textContent).toBe('2 articles') t.dispose()})Measured with Vite, minified and gzipped, Sygnal not included: 15.5 KB for i18next and this recipe (the translations are part of it). Larger apps keep each language in its own JSON file and load only the chosen one (below).
Pitfalls
Section titled “Pitfalls”- Don’t call
i18n.changeLanguage(). It changes a global inside i18next that Sygnal doesn’t see, so nothing re-renders. Keep the locale in state and usegetFixedT. - Keep
tstable per locale.getFixedTreturns a new function on each call. Called directly in.context, it gives the context a new value whenever the root’s state changes, not only when the locale does;translatorhands out the same function until the locale changes. - Load translations before switching. With translations in separate files, fetch the new language first (a
makeFetchDriverrequest on theLOCALEaction), add it withi18n.addResourceBundle(locale, 'translation', data)in anEFFECTof the reply’s action (a reducer only returns state), and only then put the new locale in state (the same action’sSTATEentry: theEFFECTruns synchronously and the view renders after the whole action, so the strings are there). A locale whose strings aren’t loaded shows the keys. - Server rendering. Take the locale from the request (the URL or
Accept-Language) and pass it in the server’s state. A locale saved only inlocalStoragereaches a server-rendered page after hydration, as aRESTOREaction: the page shows the server’s language first. - Missing keys show the key.
t('cart.titel')renders “cart.titel”. SetsaveMissingwith amissingKeyHandlerin development to log them.