Skip to content

Latest commit

 

History

History
332 lines (236 loc) · 12.3 KB

react.mdx

File metadata and controls

332 lines (236 loc) · 12.3 KB
title React
description Use SayKit with React, components, hooks, and server runtime

@saykit/react is the React integration for SayKit. It does three things:

  1. Provides a <Say> component for rendering translated content, with sub-components for plurals, ordinals, and select.
  2. Provides a <SayProvider> and useSay() for client components.
  3. Provides a small server runtime (withSay and getSay()) for server-rendered apps.

The package uses the react-server export condition, so <Say> works in both server and client components without two different imports.

Install

pnpm add @saykit/react saykit
pnpm add -D @saykit/config @saykit/format-po @saykit/transform-js @saykit/transform-jsx unplugin-saykit

Then add a SayKit build-tool plugin: unplugin-saykit for Vite/Rollup/etc. (including TanStack Start), or babel-plugin-saykit for Babel-based pipelines like Next.js, React Native, and Expo.

<Say>

<Say> is the primary way to render translated content. Write your text and embedded variables naturally; the transform extracts the literal text and rewrites the element to render the translation.

import { Say } from '@saykit/react';

function CheckoutSummary({ items, total }: { items: number; total: string }) {
  return (
    <p>
      <Say>
        {items} items &middot; total <strong>{total}</strong>
      </Say>
    </p>
  );
}

Variables become named ICU placeholders ({items}, {total}). JSX elements inside <Say> become numbered tags (<0>{total}</0>) so translators can reorder them. Childless elements extract as self-closing tags (<1/>), so there is nowhere for a translator to insert content an icon never expected.

<Say> works equally in server and client components, the package switches implementation based on the react-server condition.

Naming tags

A numbered tag tells a translator nothing about what it does. Add say-tag to any element inside <Say> to name it:

<Say>
  Signed in as <strong say-tag="bold">{user}</strong>
</Say>

The message extracts as Signed in as <bold>{user}</bold>. The attribute is compile-time only, it is stripped from the element and never reaches the DOM or a native view.

Name the tag after what it does to the text, bold, link, icon, not after the content it happens to wrap. A translator sees only the tag name and the sentence around it, so <bold> tells them how the text will look, while <user> tells them nothing they can act on.

Naming is opt-in and per element, so untagged elements keep their numbers alongside named ones. A tag must be a static string, and a valid identifier: a letter or underscore followed by letters, digits, or underscores.

say-tag names elements. To name a value you interpolate, wrap it in a single-key object: <Say>Signed in as {{ who: user.profile.name }}</Say>. Values follow the same rule as tags, a repeat is fine when it is the same value and a build error when it is not. See Placeholder names.

Two tagged elements in one message can share a name as long as they are identical, the same element with the same props, as <b say-tag="bold"> twice is. They compile to one prop, and a translator still sees a single <bold> they can move around the sentence. Only the props are compared, not the content, since that comes from the translation.

Elements that share a name but differ in any way are a build error. Each would compile to its own prop, and a translator reordering the sentence has to be able to tell them apart. Give those distinct names, `link_docs` and `link_faq` rather than `link` twice. Elements you leave untagged are unaffected, they keep their numbers.

Plurals, ordinals, select

<Say> exposes sub-components for ICU branching:

<Say.Plural _={count} one={<>{count} item in your cart</>} other={<>{count} items in your cart</>} />

<Say.Ordinal _={position} _1="1st" _2="2nd" _3="3rd" other={<>{position}th</>} />

<Say.Select _={gender} male="He liked it" female="She liked it" other="They liked it" />
JSX prop names can't start with a digit, so numeric branch keys (`1`, `2`, …) get a leading underscore (`_1`, `_2`). SayKit strips the underscore during extraction, so the resulting ICU is `1 {...}`, `2 {...}`.

Numbers, dates, times

<Say.Number>, <Say.Date>, and <Say.Time> format a value the way the active locale writes it. They are fragments rather than whole messages, so they usually nest inside a <Say>:

<Say>
  You have <Say.Number _={items.length} /> items, due
  <Say.Date _={{ dueAt }} style="medium" />
</Say>

style is optional: integer or percent on <Say.Number> (or a pattern like #,##0.00), and short, medium, long, or full on <Say.Date> and <Say.Time>. Every one of them also takes an ICU skeleton, which is how you ask for a currency or for fields the named styles cannot spell:

<Say>
  Total <Say.Number _={total} style="::currency/EUR" /> since
  <Say.Date _={{ joined }} style="::yMMMM" />
</Say>

They also stand on their own, where there is nothing to translate and nothing is extracted, as a locale-aware replacement for calling Intl yourself:

<td>
  <Say.Number _={row.total} style="::currency/EUR" />
</td>
<time dateTime={post.publishedAt.toISOString()}>
  <Say.Date _={post.publishedAt} style="long" />
</time>

See Messages for what each one extracts to.

The {"{' '}"} spacing escape is safe inside a ``. A literal expression child is read as the text it renders as, so it folds into the words around it and reaches a translator as one run of text rather than as a placeholder they can neither see nor move.

Descriptors

To attach metadata to a JSX message, a custom id or context, pass it as a prop:

<Say context="noun">Post</Say>
<Say context="verb">Post</Say>
<Say id="checkout.review">Review your order</Say>

Client components

import { SayProvider, useSay } from '@saykit/react/client';

<SayProvider>

Render SayProvider once near the top of your client tree. It takes either a store, or a locale and its messages.

A store owns the catalogue, so it can switch locale at runtime. That is what a browser app wants: every locale is loadable, and switching re-renders the tree.

Build the store in your i18n module and export it, so the provider and everything that switches locale hold the same one:

import { createCatalogue, createStore } from 'saykit';
import en from './locales/en.po';
import fr from './locales/fr.po';

export const catalogue = createCatalogue({ en, fr });

export const store = createStore(catalogue, catalogue.match(navigator.languages));
import { SayProvider } from '@saykit/react/client';
import { store } from './i18n';

<SayProvider store={store}>
  <App />
</SayProvider>;

A store is a live object and cannot cross the server/client boundary, so a server-rendered app hands over the locale and its messages instead:

<SayProvider locale={locale} messages={messages}>
  <App />
</SayProvider>

That form has only the one locale the server sent, so it cannot switch: set rejects anything else. Switch by navigating, and let the server render the new locale. See createWithSay, which fills these props in for you.

Anything beneath the provider, <Say>, <Say.Plural>, useSay(), resolves against the current locale.

useSay

If you need the view itself in a client component (for example, to compose a runtime string in an event handler), call useSay():

function ConfirmButton({ count }: { count: number }) {
  const say = useSay();

  return (
    <button onClick={() => alert(say`Delete ${count} items?`)}>
      <Say>Delete</Say>
    </button>
  );
}

useSay() subscribes to the provider's store, so the component re-renders when the locale changes and the view it returns is the current one. The view itself is immutable: it formats and reads messages but cannot switch locale.

Switching locale

Switching is the store's job, and the store is a module-scope value you already hold, so a locale picker imports it rather than reaching for a hook:

import { useSay } from '@saykit/react/client';
import { store } from '../i18n';

function LocalePicker() {
  const say = useSay();

  return (
    <select value={say.locale} onChange={(event) => store.set(event.target.value)}>
      {/* … */}
    </select>
  );
}

store.set(locale) loads that locale's messages if the catalogue does not have them yet, then swaps the view every consumer reads.

Server runtime

import { createWithSay, getSay, setSay } from '@saykit/react/server';

The two halves mirror each other: withSay is to getSay() what <SayProvider> is to useSay(). Each establishes a view, and each hands the same kind of value back.

createWithSay

createWithSay(catalogue) binds a withSay to your catalogue, normally once beside the catalogue itself:

import { createWithSay } from '@saykit/react/server';

export const withSay = createWithSay(catalogue);

withSay(Component, getLocale) wraps a server component so its view is negotiated, loaded and published into React's per-request cache() before the component renders. A concurrent request rendering another locale reads its own.

import { SayProvider } from '@saykit/react/client';
import { catalogue, withSay } from '../../i18n';

async function RootLayout({ params, children }) {
  const { locale } = await params;

  return (
    <html lang={catalogue.match(locale)}>
      <body>
        <SayProvider>{children}</SayProvider>
      </body>
    </html>
  );
}

export default withSay(RootLayout, (props) => props.params.then((params) => params.locale));

<SayProvider> takes no props here. In a server component it resolves to the react-server build of @saykit/react/client, which reads the established view and serialises that locale and its messages across the boundary, which is all a client component can be given.

Wrap every segment that renders messages, not just the outermost one. A framework is free to render a page before the layout above it - Next.js does - so a layout's view is not established yet when its page runs, and `getSay()` there would throw.

setSay

If you have already resolved the view yourself, establish it directly:

import { setSay } from '@saykit/react/server';

setSay(await catalogue.load('fr'));
One view per request. React renders a server component's children after it returns, so a view does not end where a subtree does: a second view established for another locale in the same request takes over for everything rendered after it, including the messages `` serialises to the client. Development warns when it happens. To render a second locale, give it its own request, or resolve its view with `catalogue.load(locale)` and pass it to the components that need it.

getSay

getSay() returns the view established for the request. It is the server counterpart of useSay():

import { getSay } from '@saykit/react/server';

function Page() {
  const say = getSay();
  return <h1>{say`Welcome back!`}</h1>;
}

Reach for it when you need the locale as data rather than a rendered message:

const say = getSay();
const price = new Intl.NumberFormat(say.locale, { style: 'currency', currency: 'EUR' }).format(
  total,
);

It throws if nothing established a view for the request.

Mental model

A simple way to think about the integration:

  • render translated content with <Say> in any component, server or client
  • on the server, withSay binds one view per request, and getSay() reads it
  • on the client, SayProvider supplies a store to follow, or the single locale the server sent, so the tree hydrates consistently

Next