Skip to content
loclizr

You end up with a Vite app that switches language in place, keeps the state that matters, and a test that proves both.

  1. Terminal window
    npm create vite@latest app -- --template react-ts
    cd app
    npm i
    npm i loclizr
    npx loclizr init

    Set the scripts: the three init prints, the typecheck from the Quickstart, and a pretest so 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 init seeded 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."
    }
    }
  2. 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 key remounts App on a switch and every string under it re-renders. The remount drops state under the key, so cart lives above it in Shop.

  3. 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>
    )
    }

    CourierNote sits outside the key, subscribes itself and passes the locale into each call. It re-renders in place and keeps note.

  4. 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>
    )
    }

    useSetLocale returns the same function as m.setLocale. It writes the locale cookie, so a reload keeps the choice (Language switching).

  5. src/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">
    <Parts
    of={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 Parts renders it. Terms is memoized so the test can show the key reaching it.

    Inside of={...} the prop type tells TypeScript the parts are ReactNode. Hoisted into a const, the call has nothing to read that from and infers unknown, 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’s chunks holds 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 into createElement avoids the warning: createElement('a', { className: 'terms__link', href: '#terms' }, ...chunks).

  6. Terminal window
    npm i -D vitest @testing-library/react jsdom
    src/main.test.tsx
    // @vitest-environment jsdom
    import { 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 build
    wrote src/loclizr (13 files) and locales/loclizr.context.json
    commit locales/loclizr.context.json; `loclizr check` compares it
    7 messages, 2 locales (source en), 0 errors, 0 warnings
    > app@0.0.0 test
    > vitest run
    Test Files 1 passed (1)
    Tests 1 passed (1)
    Start at 21:30:11
    Duration 450ms (environment 56%, tests 31%, import 9%, transform 3%)

    Drop the key and it fails with expected 'terms' to be 'AGB': the memoized Terms never re-renders.

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.

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.

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.