Info:
This is release candidate documentation for Stream Chat React SDK v15. For the latest stable version, see the latest version (v14).

Localization

Every string the SDK renders goes through a translation function, t(), under a stable key. The composer's placeholder, for example, is the key textareaComposer.textareaPlaceholder.sendMessage.label, and its English copy, "Send a message", is written next to the key where the component calls t(). A dictionary that maps the key to other text changes what the composer shows. Dates are formatted the same way, through keys whose values are format expressions.

This page covers the Streami18n instance that holds the dictionaries, how keys are named, how to register a language, how TypeScript checks your dictionaries, and how dates are formatted. Translating the messages users send is a separate feature, covered in Message translation.

Streami18n

Streami18n holds the dictionaries and the active language, and formats dates. It wraps i18next for the strings and Day.js for the dates. Its runtime is the @stream-io/i18n package, which the SDK depends on and shares with the React Native SDK; import everything from stream-chat-react. The SDK ships English only; every other language is a dictionary you register.

Pass an instance to Chat as i18nInstance. Without one, Chat creates an English instance of its own.

The smallest use is re-wording some of the English copy. An instance created with no language is on en, and translationsForLanguage is the dictionary for it:

import { Chat, Streami18n } from "stream-chat-react";

const streamI18n = new Streami18n({
  translationsForLanguage: {
    "emptyState.indicator.noConversationsYet.label": "Nothing here yet",
    "textareaComposer.textareaPlaceholder.sendMessage.label":
      "Write a reply...",
    "messageComposer.sendButton.send.ariaLabel": "Send this message",
  },
});

export const App = () => (
  <Chat client={client} i18nInstance={streamI18n}>
    {/* the rest of the app */}
  </Chat>
);

Every key you leave out keeps the SDK's copy. Create the instance once, at module scope as here, so Chat doesn't receive a new one on every render.

Switching the language

Streami18n.setLanguage() switches the active language. It's asynchronous and returns nothing: the new translation function is published to streamI18n.state, a state store that Chat subscribes to, and the UI re-renders in place.

import { useState } from "react";

import { streamI18n } from "./i18n";

const languages = ["en", "de", "it"];

export const LanguagePicker = () => {
  const [language, setLanguage] = useState(streamI18n.currentLanguage);

  const select = async (code: string) => {
    await streamI18n.setLanguage(code);
    // A switch that fails leaves the instance on its previous language.
    setLanguage(streamI18n.currentLanguage);
  };

  return (
    <>
      {languages.map((code) => (
        <button
          aria-pressed={code === language}
          key={code}
          onClick={() => select(code)}
        >
          {code}
        </button>
      ))}
    </>
  );
};

A language with no registered dictionary still works. The UI keeps the SDK's English copy, and dates follow that language if its Day.js locale has been imported.

Using t in your own components

useTranslationContext() returns the active translation function. Components that use it render in the active language and re-render when it changes:

import { useTranslationContext } from "stream-chat-react";

export const MemberCount = ({ count }: { count: number }) => {
  const { t } = useTranslationContext();

  return (
    <span>
      {t("channelDetail.channelMembersView.members.title", {
        count,
        defaultValue_one: "{{ count }} member",
        defaultValue_other: "{{ count }} members",
      })}
    </span>
  );
};
Value Type Description
t StreamTFunction Translates a key into the active language. Typed against the SDK's keys.
tDateTimeParser TDateTimeParser Parses a date into a Day.js (or Moment) object in the active language and timezone.
userLanguage string The language messages are translated into, from client.user.language. See UI language vs message translation.

Outside Chat, useTranslationContext() doesn't throw. It returns a translation function that renders the English copy each call passes. To read the translator of an instance outside Chat, subscribe to its state:

import { useStateStore } from "stream-chat-react";
import type { Streami18nState } from "stream-chat-react";

import { streamI18n } from "./i18n";

// At module scope: a selector created on every render subscribes again on every render.
const selector = ({ t }: Streami18nState) => ({ t });

export const CancelButton = () => {
  const { t } = useStateStore(streamI18n.state, selector);
  return <button>{t("common.cancel.label", "Cancel")}</button>;
};

Until the instance is initialized, its t renders the English copy too. Chat initializes the instance it receives. For one that no Chat uses, call Streami18n.init().

Namespaced keys

A key is a dotted identifier. Its first segment is a namespace that matches the component rendering it, and most keys end in a suffix that says how the text is used:

messageComposer.sendButton.send.ariaLabel
^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^ ^^^^^^^^^
namespace       part and thing  suffix

The usual suffixes are .label, .ariaLabel, .placeholder, .title, .description and .text. They keep one word apart when it plays two roles: messageComposer.sendButton.send.ariaLabel is what a screen reader announces for the send button, common.send.label is the visible word "Send", and the two can be translated differently.

Most namespaces are component names, such as message.*, messageComposer.*, poll.*, channelListItem.* and channelDetail.*. Copy shared by several components is under common.*. A few namespaces are not component names:

Namespace Contents
a11y.* announcements for screen readers, which never show on screen but still need translating
command.* the descriptions and arguments of the built-in slash commands
language.* the names of the languages messages can be translated into ("German", "Japanese"), in English
relativeTime.* the words of short relative dates: "Today", "Yesterday", "3d ago", "2w ago"

Keys are flat strings that contain dots, not paths into nested objects. A dictionary is a flat object: { "common.cancel.label": "Abbrechen" }, never { common: { cancel: { label: "Abbrechen" } } }.

To find the key behind a string, look in the namespace of the component that renders it, or let your editor autocomplete and show it inside a typed dictionary (see Typed dictionaries).

Plurals

A plural key has an entry per plural form, <key>_one and <key>_other. Components call it by the bare key and pass count, as MemberCount does above. A dictionary supplies the forms its language needs: i18next picks one with Intl.PluralRules, so Russian or Arabic can add _few, _many and _zero.

streamI18n.registerTranslation("ru", {
  "channelDetail.channelMembersView.members.title_one": "{{ count }} участник",
  "channelDetail.channelMembersView.members.title_few": "{{ count }} участника",
  "channelDetail.channelMembersView.members.title_many":
    "{{ count }} участников",
});

Keys that are not copy

Fifteen keys hold expressions instead of text, so they have no English copy at their call site. The SDK ships their values as bundled defaults, and every language is layered over them, so a dictionary inherits them without listing them.

Namespace Keys Value
timestamp.* 11 a date format, run through timestampFormatter
duration.* 3 a duration format, run through durationFormatter
translationBuilderTopic.* 1 a directive for the translation builder

Override a timestamp.* or duration.* key to change how a date or a duration is written: Date and time formatting explains how. Keep these keys out of anything you send to a translation service: an expression with a translated formatter name or option no longer formats the date.

Registering dictionaries

Adding a language takes three things:

  1. A dictionary of translated keys.
  2. The Day.js locale, which translates month and weekday names. It's a side-effect import.
  3. A calendar config, which translates relative dates such as "yesterday at 3:58 PM".

Put them in one module per language:

src/i18n/de.ts
import "dayjs/locale/de.js";

import type {
  DayjsLocaleConfig,
  TranslationDictionary,
} from "stream-chat-react";

export const deTranslations: TranslationDictionary = {
  "common.cancel.label": "Abbrechen",
  "common.send.label": "Senden",
  "textareaComposer.textareaPlaceholder.sendMessage.label":
    "Nachricht schreiben...",
  "channelDetail.channelMembersView.members.title_one": "{{ count }} Mitglied",
  "channelDetail.channelMembersView.members.title_other":
    "{{ count }} Mitglieder",
};

export const deDayjsLocaleConfig: DayjsLocaleConfig = {
  calendar: {
    sameDay: "[heute um] LT",
    lastDay: "[gestern um] LT",
    lastWeek: "[letzten] dddd [um] LT",
    nextDay: "[morgen um] LT",
    nextWeek: "dddd [um] LT",
    sameElse: "L",
  },
};

A dictionary can be partial. A key it leaves out renders the English copy from its call site, never a raw some.dotted.key, so you can translate ten keys or all of them and ship in between.

Register every language on one instance

Streami18n.registerTranslation(language, dictionary, dayjsLocaleConfig) adds a language to an instance. Register all of them up front, then switch with Streami18n.setLanguage():

src/i18n/index.ts
import { Streami18n } from "stream-chat-react";

import { deDayjsLocaleConfig, deTranslations } from "./de";
import { itDayjsLocaleConfig, itTranslations } from "./it";

export const streamI18n = new Streami18n({ language: "de" });

streamI18n.registerTranslation("de", deTranslations, deDayjsLocaleConfig);
streamI18n.registerTranslation("it", itTranslations, itDayjsLocaleConfig);

English needs no dictionary. Registering a dictionary for a language that already has one merges the two, and the later one wins for keys in both.

With a complete German dictionary registered, Streami18n.setLanguage("de") switches the whole UI:

English German
The UI in English
The same UI in German

One instance per language isn't needed, and it costs more: each instance is a separate i18next instance, and one swapped into Chat renders English until it has initialized.

For an app with one language, the constructor takes the same three things:

import { Streami18n } from "stream-chat-react";

import { deDayjsLocaleConfig, deTranslations } from "./de";

const streamI18n = new Streami18n({
  language: "de",
  translationsForLanguage: deTranslations,
  dayjsLocaleConfigForLanguage: deDayjsLocaleConfig,
});

Typed dictionaries

Type a dictionary as TranslationDictionary, and TypeScript checks every key against the SDK's. A mistyped key is a compile error instead of an entry that silently never applies:

import type { TranslationDictionary } from "stream-chat-react";

const typo: TranslationDictionary = {
  "common.cancel.lable": "Abbrechen",
  // TS2353: Object literal may only specify known properties,
  // and '"common.cancel.lable"' does not exist in type 'TranslationDictionary'.
};

Streami18n.registerTranslation() and translationsForLanguage take this type, so a dictionary written inline is checked without an annotation. A plural key accepts every form Intl.PluralRules can pick (_zero, _one, _two, _few, _many, _other), and a plural form on a key that isn't plural is rejected.

Warning:

Don't type a dictionary as Partial<Record<TranslationKey, string>>. TranslationKey is the set of keys t() accepts, where a plural is the bare key, so that type rejects the _one and _other entries a dictionary needs.

Reading the catalog from your editor

TranslationCatalog maps every key to its English copy. It's a type only, so it adds nothing to your bundle:

import type { TranslationCatalog } from "stream-chat-react";

type CancelCopy = TranslationCatalog["common.cancel.label"]; // "Cancel"

In a typed dictionary, autocomplete lists the keys in a namespace, and hovering a key shows its English copy.

Checking a dictionary is complete

TranslationDictionary requires no key. When a new SDK release adds keys, your build stays green, and the new strings render in English until you translate them.

To be told about them, compare your dictionary with the catalog at the type level. Declare the dictionary as const satisfies TranslationDictionary so its literal keys are kept, then exclude them from the catalog's keys:

src/i18n/de.ts
import type {
  TranslationCatalog,
  TranslationDictionary,
} from "stream-chat-react";

export const deTranslations = {
  "common.cancel.label": "Abbrechen",
  "common.back.label": "Zurück",
} as const satisfies TranslationDictionary;

// Formatter keys hold expressions, not copy, so they don't count as untranslated.
type TranslatableKey = Exclude<
  keyof TranslationCatalog,
  | `duration.${string}`
  | `timestamp.${string}`
  | `translationBuilderTopic.${string}`
>;

// Every key still missing a German translation. Hover it to read the list.
export type Untranslated = Exclude<
  TranslatableKey,
  keyof typeof deTranslations
>;

satisfies still checks every key, so a typo is an error, while as const keeps the literal keys that keyof typeof reads. A plain : TranslationDictionary annotation would widen them to the whole catalog, and Untranslated would always be empty.

Hovering Untranslated lists the missing keys, and the list shrinks as you add them. To make a missing key fail the build, which is what you want in CI for a language that has to stay complete across upgrades, assert that the list is empty:

type AssertNoneMissing<T extends never> = T;

// A compile error naming a missing key, until the dictionary is complete.
export type GermanIsComplete = AssertNoneMissing<Untranslated>;

The check runs only at compile time: there is no list of keys to compare against at runtime.

Your own keys, and LooseTranslationDictionary

LooseTranslationDictionary is TranslationDictionary plus any key you like, so one instance can carry your app's copy next to the SDK's. Nothing catches a mistyped SDK key in it, so use it only for the dictionary that needs it:

import type { LooseTranslationDictionary } from "stream-chat-react";

const withOwnKeys: LooseTranslationDictionary = {
  "common.cancel.label": "Abbrechen",
  "myApp.dashboard.title": "Übersicht",
};

streamI18n.registerTranslation("de", withOwnKeys);

t accepts only the SDK's keys, so reading one of your own goes through asDynamicKey:

import { asDynamicKey, useTranslationContext } from "stream-chat-react";

export const DashboardTitle = () => {
  const { t } = useTranslationContext();
  return <h1>{t(asDynamicKey("myApp.dashboard.title"), "Dashboard")}</h1>;
};

The second argument is the copy that renders when no dictionary has the key. The SDK uses asDynamicKey itself for the keys it only knows at runtime, such as the description of a custom slash command, which arrives from the API.

UI language vs message translation

The SDK works with two languages, and they're independent of each other:

What it decides Where it comes from
The UI language the language of the SDK's buttons, labels, placeholders and dates the Streami18n instance: language, then Streami18n.setLanguage()
userLanguage the language the Stream API translates the text of messages into client.user.language, set when the user connects

The UI language is the instance's, and nothing else changes it: Chat has no language prop and detects nothing from the browser. For a German UI, register a German dictionary and set language: "de".

userLanguage decides which translation of a message the SDK shows: it reads message.i18n.de_text when userLanguage is de. That text exists only when the API has translated the message, for example because automatic translation is turned on for your app. userLanguage follows the connected user, including a language changed after connecting.

Registering a German dictionary doesn't make the API translate messages into German, and connecting a user with language: "de" doesn't translate the UI. Set whichever you need, or both. Message translation explains the message side.

Date and time formatting

Each place the SDK shows a date reads a timestamp.* key, and each duration a duration.* key. Their values are format expressions:

Where Key Default
Date separators in a message list timestamp.DateSeparator "Today", "Yesterday", "Last Monday", then ddd, D MMM
A message's time, its "edited" tooltip, a thread-list row, a user's last activity in search results timestamp.MessageTimestamp HH:mm
A channel-list row timestamp.ChannelPreviewTimestamp the time today, then "Yesterday", the weekday, then L
A pinned message in channel details timestamp.ChannelDetailPinnedMessageTimestamp the same as a channel-list row
A member's last activity in channel details timestamp.ChannelMembersLastActive a relative compact date: "Today", "3d ago"
A poll vote, and its tooltip timestamp.PollVote, timestamp.PollVoteTooltip a relative compact date; the tooltip uses the locale's calendar
The image gallery's header timestamp.GalleryTimestamp the locale's calendar
The end time of a live location timestamp.LiveLocation the locale's calendar
A message reminder timestamp.ReminderNotification, duration.messageReminder "Today at 14:30"; within the hour, a duration such as "in 30 minutes"
The durations in the "Remind me" menu duration.remindMe "in 2 hours"
Location sharing durations duration.shareLocation "15 minutes"

The bundled defaults also hold timestamp.SystemMessage, but no component reads it: system messages show no date.

Changing a format

Override a key in a dictionary, and every component that reads it follows. This English instance shows message times on a 12-hour clock, and older date separators with the full date:

import { Streami18n } from "stream-chat-react";

const streamI18n = new Streami18n({
  translationsForLanguage: {
    "timestamp.MessageTimestamp":
      "{{ timestamp | timestampFormatter(format: h:mm A) }}",
    "timestamp.DateSeparator":
      '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Today]", "nextDay": "[Tomorrow]", "lastDay": "[Yesterday]", "nextWeek": "dddd", "lastWeek": "[Last] dddd", "sameElse": "LL" }) }}',
  },
});

To change a format in one language only, put the key in that language's dictionary. To change it in every language, pass it in runtimeDefaults instead; a language's dictionary still wins over it.

The formatting syntax

Take this expression:

{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "lastDay": "[gestern um] LT", "sameElse": "L" }) }}
  • {{ and }} mark where a value is inserted.
  • timestamp is the value: the date being formatted.
  • | separates the value from the formatter that formats it.
  • timestampFormatter is the formatter. Its options go in the parentheses, separated by ;.
  • An option's value is a word or a number, or a JSON object for calendarFormats. Inside a dictionary written as JSON, escape the object's quotes: \"lastDay\".

timestampFormatter takes these options:

Option What it does
calendar Renders the date relative to today ("Yesterday at 3:58 PM") with Day.js's calendar.
calendarFormats The calendar's six formats: sameDay, nextDay, nextWeek, lastDay, lastWeek and sameElse. Used with calendar.
format A Day.js format, such as HH:mm or dddd L. Ignored when calendar is set.
relativeCompact Renders a short relative date, see Relative compact dates.
relativeCompactMaxDays The most days ago still shown as days, such as "6d ago". Defaults to 6.
relativeCompactMaxWeeks The most weeks ago still shown as weeks, such as "3w ago". Defaults to 3.
relativeCompactWeekRounding floor or ceil: how weeks are counted. Defaults to floor.

calendarFormats replaces the locale's calendar wording for that key. Give all six formats: a format left out falls back to Day.js's English one. Square brackets keep text literal: [heute um] LT.

durationFormatter takes withSuffix, which turns "10 minutes" into "in 10 minutes" or "10 minutes ago", and format, a Day.js duration format. A third formatter, fromNowFormatter, renders a date as "3 hours ago" and is available for keys of your own.

These expressions follow i18next's formatting rules.

Component props

DateSeparator, MessageTimestamp, MessageEditedIndicator and Timestamp also take calendar, calendarFormats and format as props. DateSeparator takes the relativeCompact options too. A prop wins over the same option in the key's expression, so it changes the format of one component and leaves the others to the key.

Set the props in a replacement registered through WithComponents:

import type {
  DateSeparatorProps,
  MessageTimestampProps,
} from "stream-chat-react";
import {
  Channel,
  DateSeparator,
  MessageList,
  MessageTimestamp,
  WithComponents,
} from "stream-chat-react";

// `calendar` is set in the key's expression, so turn it off for `format` to apply.
const YearDateSeparator = (props: DateSeparatorProps) => (
  <DateSeparator {...props} calendar={false} format="D MMMM YYYY" />
);

const FullMessageTimestamp = (props: MessageTimestampProps) => (
  <MessageTimestamp {...props} format="YYYY-MM-DD HH:mm" />
);

export const ChannelWithDates = () => (
  <WithComponents
    overrides={{
      DateSeparator: YearDateSeparator,
      MessageTimestamp: FullMessageTimestamp,
    }}
  >
    <Channel channel={channel}>
      <MessageList />
    </Channel>
  </WithComponents>
);

The edited-at tooltip, the thread-list rows and the user results of a search render Timestamp and read timestamp.MessageTimestamp too, so a change to that key reaches them as well as message times. The MessageTimestamp override reaches message times, and the time in the gallery header when the media was shared in a message: a message's images and videos, and the photos and videos in channel details. Without the override, or for media that carries no message, the gallery header formats the time with timestamp.GalleryTimestamp. The date separator above a thread's parent message isn't replaced by the DateSeparator override; change it through timestamp.DateSeparator.

A formatting function

For full control over the dates in a message list, pass formatDate, a function from Date to string, to MessageList or VirtualizedMessageList. It wins over every key and prop:

import { MessageList } from "stream-chat-react";

const formatDate = (date: Date) =>
  new Intl.DateTimeFormat(undefined, {
    dateStyle: "medium",
    timeStyle: "short",
  }).format(date);

export const Messages = () => <MessageList formatDate={formatDate} />;

It reaches the times of messages and their edited-at tooltips, through MessageContext, but not a thread's parent message. In MessageList it also reaches the date separators between messages, so one function has to suit both. The floating date separator, the date separators of VirtualizedMessageList and the one above a thread's parent message keep using timestamp.DateSeparator.

Relative compact dates

relativeCompact: true renders a short label instead of a date. timestamp.ChannelMembersLastActive and timestamp.PollVote use it.

Age Renders
today relativeTime.today: "Today"
yesterday relativeTime.yesterday: "Yesterday"
up to relativeCompactMaxDays days relativeTime.daysAgo: "3d ago"
within relativeCompactMaxWeeks weeks relativeTime.weeksAgo: "2w ago"
older, or in the future the date as DD/MM/YY

The words are ordinary copy: translate them with the relativeTime.* keys. relativeTime.daysAgo and relativeTime.weeksAgo are plural keys, so they take _one, _other and any other form your language needs.

relativeCompactWeekRounding decides how weeks are counted, and when the label gives way to the date:

Value Weeks counted Shown as weeks until 8 days 27 days
floor (default) whole weeks relativeCompactMaxWeeks weeks "1w ago" "3w ago"
ceil rounded up relativeCompactMaxWeeks * 7 days "2w ago" DD/MM/YY

Both keys the SDK ships use ceil. Write relativeCompactWeekRounding: ceil in your own expression to match them.

Date and time for a new language

Two things make dates read in another language, and they're separate.

Month and weekday names come from the Day.js locale. Only en is bundled, so import the locale you need, as import "dayjs/locale/de.js". Day.js publishes locales for many languages. Without the import, L and dddd render English names whatever your dictionary says.

Day.js comes with the SDK. If your app can't import it, which happens with package managers that don't expose a dependency's dependencies, such as pnpm, add dayjs to your app and pass it as DateTimeParser, as shown in Timezones and other parsers.

Relative wording ("heute um 14:30") comes from the calendar config: dayjsLocaleConfigForLanguage on the constructor, or the third argument of Streami18n.registerTranslation(). No Day.js locale file includes one, so a new language needs both. The config's type is DayjsLocaleConfig, a Day.js locale config plus calendar. Give all six calendar formats, as in the German module in Registering dictionaries.

The calendar config reaches timestamp.GalleryTimestamp, timestamp.LiveLocation and timestamp.PollVoteTooltip, whose expressions set calendar: true with no formats of their own. Four keys set their own calendarFormats, with English words in square brackets, so the config doesn't reach them:

Key English in its calendarFormats
timestamp.DateSeparator Today, Tomorrow, Yesterday, Last
timestamp.ReminderNotification Today, Tomorrow, Yesterday, Last, at
timestamp.ChannelPreviewTimestamp Yesterday
timestamp.ChannelDetailPinnedMessageTimestamp Yesterday

Translate them by overriding the keys in the language's dictionary:

import type { TranslationDictionary } from "stream-chat-react";

export const deDateKeys: TranslationDictionary = {
  "timestamp.DateSeparator":
    '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "[Heute]", "nextDay": "[Morgen]", "lastDay": "[Gestern]", "nextWeek": "dddd", "lastWeek": "[Letzten] dddd", "sameElse": "ddd, D. MMM" }) }}',
  "timestamp.ChannelPreviewTimestamp":
    '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { "sameDay": "LT", "lastDay": "[Gestern]", "lastWeek": "dddd", "sameElse": "L" }) }}',
};

Timezones and other parsers

Dates render in the browser's timezone. To use another one, pass a timezone identifier as timezone:

import { Streami18n } from "stream-chat-react";

const streamI18n = new Streami18n({ timezone: "Europe/Prague" });

To keep dates in English whatever the UI language, set disableDateTimeTranslations: true.

DateTimeParser replaces the Day.js the SDK uses. Pass your app's own dayjs when it's a different copy from the SDK's, so the locales your app imports and the parser are the same module:

import dayjs from "dayjs";
import "dayjs/locale/de.js";
import { Streami18n } from "stream-chat-react";

const streamI18n = new Streami18n({ DateTimeParser: dayjs, language: "de" });

Moment works as a parser too. Use moment-timezone for timezone to apply. A calendar config can't be applied to Moment, so set the locale up on Moment itself.

import moment from "moment-timezone";
import { Streami18n } from "stream-chat-react";

const streamI18n = new Streami18n({
  DateTimeParser: moment,
  timezone: "Europe/Prague",
});

Custom formatters

formatters replaces a built-in formatter or adds one. A formatter is a function that receives the instance's context and returns the function that formats a value. This one formats every timestampFormatter expression with the browser's Intl.DateTimeFormat:

import { Streami18n } from "stream-chat-react";

const streamI18n = new Streami18n({
  formatters: {
    timestampFormatter:
      ({ currentLanguage }) =>
      (value) =>
        new Intl.DateTimeFormat(currentLanguage, {
          dateStyle: "medium",
          timeStyle: "short",
        }).format(new Date(value)),
  },
});

Replacing timestampFormatter replaces it for every timestamp.* key, and the options in their expressions reach your function as its third argument. To change some keys only, add a formatter under a new name and use that name in those keys' expressions.

Translation builder

Some copy can't be looked up by key alone. A notification, for example, renders different text depending on its type. The translation builder handles these cases with topics: a topic receives what t() was called with and picks a translator function that returns the text.

The SDK registers one topic, notification, which translates every notification stream-chat emits. Registering translators for your own notification types is covered in Displaying notifications.

A topic of your own extends TranslationTopic and implements TranslationTopic.translate(value, key, options). i18next runs every topic on every translation, so a topic returns value unchanged unless options carries its data:

import { Streami18n, TranslationTopic } from "stream-chat-react";
import type { TranslationTopicOptions, Translator } from "stream-chat-react";

type PresenceOptions = { presence?: "away" | "busy" };

const presenceTranslators: Record<string, Translator<PresenceOptions>> = {
  away: ({ t }) => t("myApp.presence.away", "Away"),
  busy: ({ t }) => t("myApp.presence.busy", "Busy"),
};

class PresenceTopic extends TranslationTopic<PresenceOptions> {
  constructor({ i18next }: TranslationTopicOptions) {
    super({ i18next, translators: presenceTranslators });
  }

  translate = (value: string, key: string, options: PresenceOptions) => {
    const translator = options.presence
      ? this.translators.get(options.presence)
      : undefined;
    if (!translator) return value;
    const t = (copyKey: string, defaultValue: string) =>
      this.i18next.t(copyKey, defaultValue);
    return translator({ key, options, t, value }) ?? value;
  };
}

export const streamI18n = new Streami18n({
  runtimeDefaults: {
    "translationBuilderTopic.presence": "{{value, presence}}",
  },
  translationBuilderTopics: { presence: PresenceTopic },
});

translationBuilderTopics registers the topic under a name, here presence, next to the SDK's own. runtimeDefaults adds a key for it to every language. Calling t("translationBuilderTopic.presence", { presence: "away" }) then returns "Away", or its translation.

TranslationBuilder.registerTranslators(topic, translators), on streamI18n.translationBuilder, adds translators to a registered topic, and TranslationBuilder.removeTranslators(topic, names) removes them.

Streami18n reference

Constructor options

Option Type Default Description
language string 'en' The language to render.
translationsForLanguage TranslationDictionary The dictionary for language.
dayjsLocaleConfigForLanguage DayjsLocaleConfig The Day.js locale config and calendar wording for language.
DateTimeParser Day.js or Moment Day.js The date library. Day.js gets the plugins the formatters need.
timezone string the browser's The timezone dates render in.
disableDateTimeTranslations boolean false Keeps dates in English whatever the language.
formatters object the built-in three Replaces or adds formatters. See Custom formatters.
runtimeDefaults Record<string, string> Keys added to every language, merged over the SDK's bundled defaults.
translationBuilderTopics object Topics for the translation builder, added to the SDK's notification topic.
parseMissingKeyHandler function see below Called for a key with no translation, no bundled value and no default.
i18nextConfigOverrides Partial<InitOptions> Any i18next init option, applied over the SDK's. Leave keySeparator and nsSeparator as they are.
logger function console.warn Receives the instance's warnings.
debug boolean false Turns on i18next's debug output.

parseMissingKeyHandler never sees untranslated copy: a prose key with no translation renders its English default without calling it. It's called only for a key with nothing to render at all, for example one passed through asDynamicKey with no default. The default handler logs a warning and returns the key.

import { Streami18n } from "stream-chat-react";

const streamI18n = new Streami18n({
  parseMissingKeyHandler: (key) => {
    console.error(`Nothing to render for the translation key ${key}`);
    return "";
  },
});

To find copy that isn't translated yet, use the compile-time check.

Methods

Method Description
registerTranslation(language, dictionary, dayjsLocaleConfig?) Adds or extends a language. Merged over what the language already has.
setLanguage(language) Switches the active language. Returns a promise of nothing; the new translator is published to state.
init() Initializes i18next. Chat calls it. Calling it again returns the same promise.
overrideTFunction(t) Replaces the translation function, for an app that already has its own i18n layer.

Properties

Property Type Description
currentLanguage string The active language.
registeredLanguages ReadonlySet<string> The languages a dictionary was registered for, plus en.
initialized boolean Whether init() has finished. Until then, t returns each key's English copy.
t StreamTFunction The current translation function.
tDateTimeParser TDateTimeParser The current date parser.
state StateStore<Streami18nState> { t, tDateTimeParser, language, initialized }. Subscribe to it to follow language changes.
translationBuilder TranslationBuilder The translation builder.
i18nInstance i18n The underlying i18next instance.

Used in

Pitfalls

  • A dictionary and no dates. A dictionary translates the copy. Month names need the Day.js locale import, and relative dates need the calendar config. Translating one and forgetting the others is the most common gap.
  • Untyped dictionaries. A key from an older version or a typo never matches, and nothing reports it. Type dictionaries as TranslationDictionary.
  • Setting the language through the user. language in connectUser sets the language messages are translated into, not the UI's. Set the UI language on the Streami18n instance.
  • Sending formatter keys to translators. The timestamp.*, duration.* and translationBuilderTopic.* values are expressions, not copy. A translated expression stops formatting.
  • A new instance on every render. new Streami18n() inside a component gives Chat a new instance each time, which initializes again. Create it at module scope or in useMemo.
  • New copy after an upgrade. New keys render in English without a warning. For a language that has to stay complete, run the completeness check in CI.