Skip to content

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.

Terminal window
npm install i18next
i18n.js
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 does
const 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: false with the translations inline makes t() 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 a t bound to one locale. The app never calls i18n.changeLanguage(): the locale lives in Sygnal’s state, not in the i18next instance, so several apps (or tests) can show different languages at once.
  • translator keeps one t per locale, so the function only changes when the locale does.
App.jsx
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'] })
Cart.jsx
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.
  • t for every component: App.context.t is computed from the root’s state, and every component below reads it as context.t, however deep. When the locale changes, the context changes, and the components that use it render again. The root itself calls translator(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: 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, from Intl.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 with Intl.NumberFormat for the locale: “€4.50” in English, “4,50 €” in French. number, datetime, relativetime and list work the same way.
  • Put the locale in a lang attribute (here on <main>), so screen readers pronounce the text in the right language. For the whole page, set document.documentElement.lang from an EFFECT in the LOCALE entry and in BOOTSTRAP.
  • 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, alt text and titles too: aria-label={t('cart.remove')}.

renderComponent gives a persisting root a fake storage; fill it to start in a saved locale, and read what was saved with t.storage():

App.test.jsx
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):

Cart.test.jsx
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).

  • 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 use getFixedT.
  • Keep t stable per locale. getFixedT returns 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; translator hands out the same function until the locale changes.
  • Load translations before switching. With translations in separate files, fetch the new language first (a makeFetchDriver request on the LOCALE action), add it with i18n.addResourceBundle(locale, 'translation', data) in an EFFECT of the reply’s action (a reducer only returns state), and only then put the new locale in state (the same action’s STATE entry: the EFFECT runs 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 in localStorage reaches a server-rendered page after hydration, as a RESTORE action: the page shows the server’s language first.
  • Missing keys show the key. t('cart.titel') renders “cart.titel”. Set saveMissing with a missingKeyHandler in development to log them.