Frameworks
React
Key the root on useLocale, pass the locale per call where state must survive, render markup with Parts.
You end up with a Vite app that switches language in place, keeps the state that matters, and a test that proves both.
-
Create the app
Section titled “Create the app”Terminal window npm create vite@latest app -- --template react-tscd appnpm inpm i loclizrnpx loclizr initSet the scripts: the three
initprints, thetypecheckfrom the Quickstart, and apretestso the test runs against a fresh build.package.json "scripts": {"predev": "loclizr build --no-fail","dev": "vite","prebuild": "loclizr build","build": "tsc -b && vite build","lint": "oxlint","preview": "vite preview","pretypecheck": "loclizr build","typecheck": "tsc -b","pretest": "loclizr build","test": "vitest run"}Replace the catalog
initseeded with these two:locales/en.json {"app": {"more": "One item more","note": "Note for the courier","notePlaceholder": "Leave it with the neighbour","noteKeeps": "This panel renders in place, so the note you type survives a language change."},"cart": {"greeting": "Hi {name}, your cart is ready","items": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}"},"terms": {"accept": "Read our <link>terms</link> before you continue."}}locales/de.json {"app": {"more": "Ein Artikel mehr","note": "Nachricht an den Boten","notePlaceholder": "Bitte beim Nachbarn abgeben","noteKeeps": "Dieses Feld wird an Ort und Stelle neu gerendert, deine Notiz überlebt also den Sprachwechsel."},"cart": {"greeting": "Hallo {name}, dein Warenkorb ist bereit","items": "{count, plural, =0 {Dein Warenkorb ist leer} one {# Artikel in deinem Warenkorb} other {# Artikel in deinem Warenkorb}}"},"terms": {"accept": "Lies unsere <link>AGB</link>, bevor du fortfährst."}} -
Key the root on the locale
Section titled “Key the root on the locale”src/main.tsx import { StrictMode, useState } from 'react'import { createRoot } from 'react-dom/client'import { useLocale } from 'loclizr/react'import './index.css'import { App, type Cart } from './App'import { CourierNote } from './CourierNote'const openingCart: Cart = { name: 'Ada', count: 3 }function Shop() {const locale = useLocale()const [cart, setCart] = useState(openingCart)return (<><App key={locale} cart={cart} onChange={(patch) => setCart((current) => ({ ...current, ...patch }))} /><CourierNote /></>)}createRoot(document.getElementById('root')!).render(<StrictMode><Shop /></StrictMode>,)A message call subscribes to nothing, so the
keyremountsAppon a switch and every string under it re-renders. The remount drops state under the key, socartlives above it inShop. -
Pass the locale per call where state must survive
Section titled “Pass the locale per call where state must survive”src/CourierNote.tsx import { useState } from 'react'import { useLocale } from 'loclizr/react'import * as m from './loclizr/messages'export function CourierNote() {const locale = useLocale()const [note, setNote] = useState('')return (<section className="note"><label htmlFor="note">{m.app_note({}, { locale })}</label><textarea id="note" value={note} placeholder={m.app_notePlaceholder({}, { locale })} onChange={(event) => setNote(event.target.value)} /><p>{m.app_noteKeeps({}, { locale })}</p></section>)}CourierNotesits outside the key, subscribes itself and passes the locale into each call. It re-renders in place and keepsnote. -
Add a switcher
Section titled “Add a switcher”src/Switcher.tsx import { useLocale, useSetLocale } from 'loclizr/react'import * as m from './loclizr/messages'export function Switcher() {const locale = useLocale()const setLocale = useSetLocale()return (<nav>{m.locales.map((tag) => (<button key={tag} type="button" aria-pressed={tag === locale} onClick={() => setLocale(tag)}>{tag}</button>))}</nav>)}useSetLocalereturns the same function asm.setLocale. It writes thelocalecookie, so a reload keeps the choice (Language switching). -
Render markup with
Section titled “Render markup with Parts”Partssrc/App.tsx import { memo } from 'react'import { Parts } from 'loclizr/react'import * as m from './loclizr/messages'import { Switcher } from './Switcher'export type Cart = { name: string; count: number }const Terms = memo(function Terms() {return (<p className="terms" id="terms"><Partsof={m.terms_accept({link: (chunks) => (<a className="terms__link" href="#terms">{chunks}</a>),})}/></p>)})export function App({ cart, onChange }: { cart: Cart; onChange: (patch: Partial<Cart>) => void }) {return (<main><Switcher /><h1>{m.cart_greeting({ name: cart.name })}</h1><p>{m.cart_items({ count: cart.count })}</p><button type="button" onClick={() => onChange({ count: cart.count + 1 })}>{m.app_more()}</button><Terms /></main>)}A markup message returns an array, not a string, and
Partsrenders it.Termsis memoized so the test can show the key reaching it.Inside
of={...}the prop type tells TypeScript the parts areReactNode. Hoisted into aconst, the call has nothing to read that from and infersunknown, so{chunks}does not compile. Pass the type:import type { ReactNode } from 'react'const terms = m.terms_accept<ReactNode>({ link: (chunks) => <a href="#terms">{chunks}</a> })Annotating the handler works too:
(chunks: readonly ReactNode[]) => ....Once tags nest, as in
<link><b>full</b> terms</link>, the outer handler’schunksholds an element and React logs a key warning in development; a translator may nest tags the source keeps apart. The output is still correct. Spreading the chunks intocreateElementavoids the warning:createElement('a', { className: 'terms__link', href: '#terms' }, ...chunks). -
Test the switch
Section titled “Test the switch”Terminal window npm i -D vitest @testing-library/react jsdomsrc/main.test.tsx // @vitest-environment jsdomimport { act, fireEvent, screen } from '@testing-library/react'import { expect, test } from 'vitest'test('a switch rewrites the page and keeps state above the key', async () => {document.body.innerHTML = '<div id="root"></div>'await act(() => import('./main'))fireEvent.click(screen.getByRole('button', { name: 'One item more' }))fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Back door' } })expect(screen.getByRole('heading').textContent).toBe('Hi Ada, your cart is ready')fireEvent.click(screen.getByRole('button', { name: 'de' }))expect(screen.getByRole('heading').textContent).toBe('Hallo Ada, dein Warenkorb ist bereit')expect(screen.getByText('4 Artikel in deinem Warenkorb')).toBeDefined()expect(screen.getByRole('link').textContent).toBe('AGB')expect(screen.getByRole('textbox')).toHaveProperty('value', 'Back door')expect(document.cookie).toBe('locale=de')})npm test > app@0.0.0 pretest> loclizr buildwrote src/loclizr (13 files) and locales/loclizr.context.jsoncommit locales/loclizr.context.json; `loclizr check` compares it7 messages, 2 locales (source en), 0 errors, 0 warnings> app@0.0.0 test> vitest runTest Files 1 passed (1)Tests 1 passed (1)Start at 21:30:11Duration 450ms (environment 56%, tests 31%, import 9%, transform 3%)Drop the
keyand it fails withexpected 'terms' to be 'AGB': the memoizedTermsnever re-renders.
The <html lang> warning
Section titled “The <html lang> warning”Outside production useLocale compares <html lang> with the resolved locale, assigns it when
they differ or the attribute is missing, and warns once in the console unless the two differ only
in letter case. A server-rendered page avoids it by rendering lang from the Content-Language
header (Server rendering).
A client-only app never sees the disagrees with the resolved locale warning after a reload that
follows a switch: when the store reads the de cookie it sets lang to match, whatever
index.html says.
jsdom’s <html> has no lang, so a test that renders before any setLocale warns once per
file; Testing shows the setup line that keeps it out
of test output.
React Compiler
Section titled “React Compiler”m.app_more() reads mutable external state and takes no arguments, so a memoizing compiler may
cache its result for a component instance’s lifetime. Both patterns above hold up:
| Pattern | Why it is sound |
|---|---|
| Keyed root | Caches are per instance, and a remount throws them away. |
Per-call { locale } |
A value the compiler can see change. |
The keyed root misses a second createRoot, such as a separately mounted toast layer, and a string
computed once at module scope. Pass { locale } per call there.
Peer dependency
Section titled “Peer dependency”React is an optional peer dependency (18 or newer); an app that never imports loclizr/react ships
no React binding. Signatures are in Runtime API.