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

Upgrade to v15

This page lists what changed between v14 and v15, and what to change in your app. Each change links to the v15 page that explains how the new version works.

v15 needs stream-chat 10, version 10.0.0-rc.20 or later. Upgrade both packages together, as Installation describes. Many of the changes below come from stream-chat 10. This page covers the ones a React app meets. The complete list of client changes is stream-chat v9 to v10 breaking changes in the stream-chat repository.

Quick migration checklist

  1. Upgrade both packages: npm install stream-chat@rc stream-chat-react@rc.
  2. Move client options under client. useCreateChatClient takes config instead of options. See Creating the client.
  3. Update how you read data. Your custom fields are under custom, so a channel's name is channel.data?.custom?.name. Server timestamps are nanosecond numbers, and sorts are arrays. See Data shapes.
  4. Rewrite every ChannelList. Create a ChannelPaginator, register it on client.channelManager, and pass it as the paginator prop. See Channel lists.
  5. Watch a channel before you pass it to Channel. Channel no longer queries. Use Channel.watch(). See Channel no longer queries the channel.
  6. Remove Window, and pass thread to Thread. Render the thread's header, list and composer as its children. See Window is gone and Thread takes a thread and renders its children.
  7. Replace useChannelStateContext and useChannelActionContext. Both contexts are removed. Read the channel's stores with useChannel() and useStateStore, and call methods on the channel. See Removed contexts.
  8. Replace setActiveChannel. Open a channel with useWorkspaceNavigation().openChannel(channel). See ChatContext has no active channel.
  9. Import ChatView from stream-chat-react/slot-layout, and replace ChatView.Channels, ChatView.Threads and ChatView.ThreadAdapter with views and slots. See ChatView is a slot layout.
  10. Move the do*Request props to client.config. See Channel props that moved.
  11. Rename your translation keys. See Localization.
  12. Rename Message overrides to MessageUI, and disableDateSeparator to withDateSeparator. See Components.
  13. Replace connection.changed listeners. See Connection status is two facts now.

What's new

Localization

Two changes affect anyone who customized translations, and a third affects code that calls the Streami18n instance directly. If you use the SDK in English and never touched i18n, there is nothing to do.

The v15 behaviour is on Localization. This section is the migration.

English is the only bundled language

The de, es, fr, hi, it, ja, ko, nl, pt, ru and tr dictionaries are removed, along with their dayjs locale data. The deTranslations to trTranslations exports are gone with them, and so is enTranslations.

Those dictionaries were imported statically and copied into Streami18n when it was created, so they shipped even in apps that only rendered English. Removing them, together with the key change below, takes about 112 KB gzipped out of the bundle.

If you rendered one of them, supply it yourself. The last published files are in the SDK's git history:

git show v14.13.2:src/i18n/de.json > de.json

Its keys are the old natural-language ones, so rename them (see below) before you register it. You also need the dayjs locale and the calendar formats the SDK used to bundle for each language. Date and time for a new language shows both.

SupportedTranslations and isLanguageSupported are removed. language, and the language option of Streami18n, are plain strings, because the languages are whatever you register.

Chat's defaultLanguage prop is removed. In v14, when you passed no i18nInstance, Chat created one in the user's language, else the browser's language if the SDK bundled it, else defaultLanguage. In v15 the instance Chat creates is English. Delete the prop. For a non-English UI, register a dictionary and set language on your Streami18n instance. For translated messages, set language on the user in client.connectUser().

userLanguage, the language the SDK reads auto-translated message text in, no longer falls back to the browser's language either. A registered German dictionary says nothing about whether the API produces message.i18n.de_text: without client.user.language the API doesn't translate. userLanguage follows client.user.language, or en when that's unset, and it follows changes made after the user connects.

Translation keys are namespaced identifiers

A key used to be the English copy itself. It's now a stable dotted identifier, and each call passes the English copy as i18next's defaultValue. The v14 key aria/Send became:

t("messageComposer.sendButton.send.ariaLabel", "Send");

The aria/ prefix is gone. What the text is for moved into the last segment of the key, ariaLabel here.

Warning:

Renaming your keys isn't optional, and a missed one fails quietly. An old key never matches, so your translation stops applying and the SDK's English copy renders instead, with no error and nothing in the console.

The table that maps v14 keys to v15 keys, 621 rows, is ai-docs/i18n-v15-key-map.json in the SDK repository. It covers most v14 keys. A v14 key with no row is mostly copy that v15 no longer renders. If its copy still shows in your UI, find the v15 key by its English text in the TranslationCatalog type.

A few rows of the table:

{
  "keys": {
    "Cancel": { "key": "common.cancel.label", "prose": true, "shared": true },
    "aria/Send": {
      "key": "messageComposer.sendButton.send.ariaLabel",
      "prose": true
    },
    "{{ count }} members": {
      "key": "channelDetail.channelMembersView.members.title",
      "prose": true,
      "plural": true
    },
    "giphy-command-args": { "key": "command.giphy.args", "prose": true },
    "language/de": { "key": "language.de", "prose": true },
    "timestamp/MessageTimestamp": {
      "key": "timestamp.MessageTimestamp",
      "prose": false
    }
  }
}

A German dictionary in v14:

i18n.registerTranslation("de", {
  Cancel: "Abbrechen",
  "aria/Send": "Senden",
  "{{ count }} members_one": "{{ count }} Mitglied",
  "{{ count }} members_other": "{{ count }} Mitglieder",
});

The same dictionary in v15:

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

const i18n = new Streami18n();

const de: TranslationDictionary = {
  "common.cancel.label": "Abbrechen",
  "messageComposer.sendButton.send.ariaLabel": "Senden",
  "channelDetail.channelMembersView.members.title_one": "{{ count }} Mitglied",
  "channelDetail.channelMembersView.members.title_other":
    "{{ count }} Mitglieder",
};

i18n.registerTranslation("de", de);

Let TypeScript catch the ones you miss

Typing the dictionary as TranslationDictionary turns the quiet failure into a compile error, which is the fastest way through the rename:

const de: TranslationDictionary = {
  Cancel: "Abbrechen", // TS2353: 'Cancel' does not exist in type ...
};

Streami18n.registerTranslation() and translationsForLanguage already take that type, so a dictionary written inline is checked without the annotation. Typed dictionaries covers the rest, including how to learn that a later release added a key.

Keys whose value is not copy

The timestamp/*, duration/* and translationBuilderTopic/* groups became timestamp.*, duration.* and translationBuilderTopic.*. Their values are dayjs and i18next expressions, not text, and they no longer live in a locale file. The SDK ships them as defaults that every language you register is layered over, so you list one only to change it. Four timestamp/relative* keys were text, and became relativeTime.today, relativeTime.yesterday, relativeTime.daysAgo and relativeTime.weeksAgo.

Four timestamp.* keys put English day words, such as "Today", into their calendarFormats argument, and need an override to translate them. See Keys that are not copy.

There is no en.json

The SDK no longer checks in an English locale file. The copy is written at each t() call, so a committed catalog would be a second copy that could go stale.

To get a file for a translator or a translation service, generate it from a clone of the SDK with yarn i18n:export. To see a key's English copy in your editor, index the type-only TranslationCatalog export.

The translation runtime

Streami18n is built on the shared @stream-io/i18n package. You still import it from stream-chat-react, and its constructor options are the same. Code that calls the instance directly needs these changes:

v14 v15
await i18n.getTranslators() await i18n.init(), which resolves to the instance's state, t and tDateTimeParser included, and rejects if it fails
assigning i18n.t i18n.overrideTFunction(t)
const t = await i18n.setLanguage("de") setLanguage() resolves to nothing. Read t from the instance's state
i18n.registerSetLanguageCallback(callback) subscribe to i18n.state
i18n.getAvailableLanguages() i18n.registeredLanguages, a read-only Set
i18n.getTranslations() removed. The English copy is in the TranslationCatalog type
i18n.geti18Instance() removed
translators from useChat({ client, i18nInstance }) useStreami18n({ client, i18nInstance })

The current t is in the instance's state:

await i18n.setLanguage("de");
const { t } = i18n.state.getLatestValue();

@stream-io/i18n depends on i18next and dayjs. If you declared them only for this SDK, you can remove them. If you keep dayjs, use the same version range, so the locale you import registers on the copy that formats the dates.

Creating the client

stream-chat 10 builds the client from one configuration tree, the same tree client.config.set() takes. Most of the options v9 took at the top level now go under client. useCreateChatClient passes that tree as config, which replaces its options prop.

In v14:

const client = useCreateChatClient({
  apiKey,
  options: { baseURL: "https://chat.example.com", timeout: 6000 },
  tokenOrProvider: userToken,
  userData: { id: userId },
});

In v15:

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

export const App = () => {
  const client = useCreateChatClient({
    apiKey,
    config: {
      client: {
        axiosRequestConfig: { timeout: 6000 },
        baseURL: "https://chat.example.com",
      },
    },
    tokenOrProvider: userToken,
    userData: { id: userId },
  });

  if (!client) return null;

  return <Chat client={client}>{/* your app */}</Chat>;
};

A client you create yourself takes the same tree: new StreamChat(apiKey, { client: { baseURL } }). The secret argument is gone, because server-side code belongs in @stream-io/node-sdk. In plain JavaScript, a v9 options object still runs, but the client ignores its keys and logs a warning for each.

Where the common v9 options went:

v9 option v10 setting
timeout, headers and the other top-level axios options client.axiosRequestConfig
baseURL client.baseURL
enableWSFallback client.wsConnection.enableWSFallback
wsUrlParams client.wsConnection.urlParams
recoverStateOnReconnect client.connectionRecovery.enabled
persistUserOnConnectionFailure client.persistUserOnConnectionFailure
isLocalUnreadCountEnabled channel.readEvents.localUnreadCountEnabled
logger removed. Configure logging with chatLoggerSystem from stream-chat
device removed. Call client.createDevice() after connecting
warmUp, enableInsights, disableCache removed

Configuration explains the tree, which settings can change after the client is created, and setup functions.

Data shapes

stream-chat 10 types its data from the API's specification. Three changes reach almost every app.

Custom fields are under custom

Your own fields on a message, a channel, a user or another object are no longer spread at the top level. They're in the object's custom field:

v9 v10
message.priority message.custom.priority
channel.data?.name channel.data?.custom?.name
channel.data?.image channel.data?.custom?.image
Channel.sendMessage({ text, ... }) Channel.sendMessage({ message: { text, custom: {} } })

The channel's name and image are the reads most apps have to fix. Filtering is the exception: filter a channel's name as name, and other custom fields as "custom.<field>". To type your fields, extend the Custom*Data interfaces, as Custom data types shows.

Timestamps are nanosecond numbers

Every date the server sends, such as created_at, is a TimestampNS: Unix time in nanoseconds, as a number. In v9 it was an ISO string, and messages carried Dates.

  • Compare and sort timestamps as numbers: a.created_at < b.created_at.
  • Convert where a timestamp meets a Date, a date library or Date.now(), with nsToDate(), convertTimestampToDate() or nsToMs() from stream-chat.
  • new Date(message.created_at) and dayjs(message.created_at) read nanoseconds as milliseconds. The first fails to compile where you use the result as a Date. The second compiles and renders "Invalid Date".

The React props that carry a timestamp changed with it. MessageList's headerPosition is a TimestampNS, and so are lastRead in processMessages and lastReadDate in the VirtualizedMessageList render props. Components that render a date, such as DateSeparator, still take a Date. Timestamps lists the helpers.

Sorts are arrays

Every sort is an array of { field, direction } objects, typed SortParamRequest[]. The object form and the per-endpoint sort types, such as ChannelSort, are removed:

import type { SortParamRequest } from "stream-chat";

// v9: { pinned_at: -1, last_message_at: -1 }
const sort: SortParamRequest[] = [
  { direction: -1, field: "pinned_at" },
  { direction: -1, field: "last_message_at" },
];

Keep the order of the fields: the first one sorts first.

Other client changes

These come up less often. The complete list has each one in detail.

  • Raw requests and the optimistic layer. client.queryChannels(), Channel.sendMessage(), Channel.sendReaction(), client.updateMessage() and client.deleteMessage() are plain requests that change no local state. The v9 behaviour of queryChannels is client.queryChannelsAndHydrate(). Sending, editing and deleting with an optimistic update goes through the composer and channel.messageOperations; see Message operations.
  • Channels the client holds. client.activeChannels, client.getChannelById() and client.getChannelByMembers() are removed. Use client.channelManager.get(cid), client.channelManager.values(), and client.channel(). See Shared entities.
  • Channel state. channel.state is a store, the message list moved to channel.messagePaginator, and pinned messages to channel.pinnedMessagesPaginator. See Channel state is one store.
  • Channel.watch(). It resolves with the channel instance to use, not the server's response: read the data from the channel, such as channel.data, channel.state.members and channel.messagePaginator.items. It sends a request only when the channel isn't loaded yet, or when you pass options it wasn't watched with, so it no longer refreshes a watched channel: call Channel.reload() for that.
  • Channel.query(). Its second argument, the message set to add the messages to, is removed, and the messages it returns aren't added to channel.messagePaginator. Open a channel with Channel.watch(), and load more messages with MessagePaginator.toTail(), MessagePaginator.toHead() or MessagePaginator.jumpToMessage().
  • Threads. A thread's replies are in thread.messagePaginator, and the thread list is client.threads.paginator. Get a thread with client.threads.ensure({ channel, parentMessage }), not new Thread(). Thread.activate() returns the function that ends the activation, and Thread.deactivate() is removed.
  • Marking read. Channel.markRead() sends the request directly. The v9 Channel.markRead() path, through the delivery reporter, is Channel.markReadViaReporter(), which also calls your markReadRequest handler if you register one.
  • Events. Event is a union of event types, EventTypes is EventType, and custom event fields are under event.custom.
  • Errors. API errors are StreamAPIError, which replaces ErrorFromResponse.
  • StateStore. stream-chat no longer exports it. Import it from @stream-io/state-store, and keep one copy of that package installed.
  • Link previews. The composer loads link previews by default where the channel type enriches URLs. To turn them off: client.config.set({ messageComposer: { linkPreviews: { enabled: false } } }).
  • Node.js. stream-chat needs Node.js 22.18 or later, and no longer bundles a WebSocket for Node.

Renamed and deprecated APIs

The left column keeps working in v15. These are deprecated aliases, not removed APIs, but new code should use the right column.

Deprecated Use
client.setMessageComposerSetupFunction(fn) client.config.setSetupFunction("messageComposer", fn)
Thread.markAsRead() Thread.markRead()
client.userID, client.clientID client.userId, client.clientId

The paginators' old method names are removed, not deprecated. A call to them doesn't compile:

Removed Use
paginator.next() paginator.toTail()
paginator.prev() paginator.toHead()
paginator.nextDebounced() paginator.toTailDebounced()
paginator.prevDebounced() paginator.toHeadDebounced()
paginator.hasNext paginator.hasMoreTail
paginator.hasPrev paginator.hasMoreHead
paginator.headItems paginator.anchoredHeadItems
paginator.headmostItem paginator.anchoredHeadItem

A paginator's state has no hasNext and hasPrev either, so a selector reading them gets undefined: select hasMoreTail and hasMoreHead. In the same way, a cursor's next and prev are tailward and headward, and the hasNext and hasPrev of useChannelPaginatorState() are hasMoreTail and hasMoreHead.

In a message list, the tail is where older messages load, and the head is the newest message. Head and tail explains both ends.

The paginator classes moved inside stream-chat to pagination/paginators/. Import them from stream-chat, never from a path inside stream-chat/dist.

Connection status is two facts now

Until v15 the SDK had one notion of "online": whether its WebSocket was up. It inferred the device's network from that socket, which is wrong both ways. A socket dies on working Wi-Fi when the server closes it or a token expires, and a device can get its network back seconds before the socket. stream-chat 10 splits them into client.networkConnection, whether the device has a network, and client.wsConnection, whether the socket is up.

You're affected if you listen for connectivity, render your own offline UI, or read the socket directly:

v14 v15
connection.changed event client.wsConnection.state or client.networkConnection.state. In components, useWSConnectionState() or useNetworkConnectionState()
transport.changed event the local connection.fallback_activated event
client.wsConnection, the socket or null client.wsConnection always exists. It reports isHealthy and holds the socket's settings
client.defaultWSTimeout client.wsConnection.config.connectTimeoutMs, set as client.wsConnection.connectTimeoutMs in the configuration
client.wsFallback removed
ThreadManagerState.lastConnectionDropAt lastUnhealthyAt in client.wsConnection.state

Two of these fail without a compile error, so check for them by hand:

  • connection.changed handlers. The event no longer exists, so a listener for it doesn't compile. Subscribe to the store you meant instead. If the handler showed a "connection lost" banner, it relied on the event holding a drop back for five seconds, so a short flap didn't flash the banner. The stores publish every change at once, so wait client.wsConnection.config.offlineNotificationDisplayDelayMs yourself, and cancel if the socket comes back.
  • Options passed as a separate object. TypeScript rejects the old option names when you pass the options inline. An options object built elsewhere gets past that check, and the client ignores it.

The two stores name their fields differently. The socket's isHealthy is always a boolean. The network's isOnline is undefined until a reporter has reported, so test it with === false.

On React Native, the browser events the built-in network reporter uses don't exist. The client installs a stand-in that repeats what the socket reports, so nothing breaks, but it adds no information. Install a reporter built on the platform's network API for a real signal. See Device network.

Watching changed too. A request that watches a channel or subscribes to presence waits for the WebSocket's connection id, which the server ties the subscription to. Before, it went out without one, got a 200, and was registered nowhere. It waits rather than fails, so it rides out a reconnect. It fails when no socket is open or opening, or when the socket is closed while it waits. A query that asks for neither isn't held. See "No connection id is available".

Recovery after a reconnect changed too. stream-chat 9 re-queried the loaded channels in one request. stream-chat 10 queries again the channels each registered channel list has loaded, up to 150, page by page, and removes those no page returned. It also reloads each active channel and thread, and then dispatches connection.recovered. See Connection recovery.

Channels

Channel props that moved

Channel has one prop: the channel. Everything else that used to hang off it now belongs to the component or the layer that uses it:

v14 prop on Channel v15
giphyVersion, imageAttachmentSizeHandler, videoAttachmentSizeHandler, shouldGenerateVideoThumbnail props of Attachment. Register an Attachment wrapper through WithComponents
onMentionsClick, onMentionsHover props of MessageList
doSendMessageRequest, doUpdateMessageRequest, doDeleteMessageRequest, doMarkReadRequest request handlers in client.config. See Request handlers
initializeOnMount, channelQueryOptions removed. See Channel no longer queries the channel
activeUnreadHandler removed. See Channel no longer writes document.title
EmptyPlaceholder removed. See ChannelProps.channel is required
allowConcurrentAudioPlayback removed. See Audio playback
markReadOnMount removed. The message list marks the channel read when the user is at the newest message
skipMessageDataMemoization removed. Messages come from the channel's message list store

The request handlers change shape as well as place. The message handlers take a params object instead of positional arguments, plus the default request as a second argument. They resolve to { message }. They're registered once per client, not per mounted Channel, so to treat channels differently, branch inside the handler.

In v14:

<Channel channel={channel} doSendMessageRequest={mySend}>
  {/* ... */}
</Channel>

In v15:

client.config.set({
  channel: {
    requestHandlers: {
      sendMessageRequest: async ({ message, options }) => ({
        message: await mySend(message, options),
      }),
    },
  },
});

Thread replies go through the same channel handlers. The thread key takes only markReadRequest, which runs when a thread is marked read.

useChannelEditMessageHandler is removed with the props. An updateMessageRequest handler covers edits.

Channel no longer queries the channel

Channel used to call Channel.watch() when it mounted a channel that wasn't loaded, and initializeOnMount turned that off. Both are gone, along with channelQueryOptions and the getChannel helper. Whoever supplies the channel loads it:

import { useEffect, useState } from "react";
import type { Channel as StreamChannel } from "stream-chat";
import {
  Channel,
  ChannelPlaceholder,
  MessageComposer,
  MessageList,
  useChatContext,
} from "stream-chat-react";

export const SupportChannel = () => {
  const { client } = useChatContext();
  const [channel, setChannel] = useState<StreamChannel>();

  useEffect(() => {
    client.channel("messaging", "support").watch().then(setChannel);
  }, [client]);

  if (!channel) return <ChannelPlaceholder>Loading...</ChannelPlaceholder>;

  return (
    <Channel channel={channel}>
      <MessageList />
      <MessageComposer />
    </Channel>
  );
};

client.channel() returns the channel the client holds for that id, or creates it, without a request. Channel.watch() is safe to call again, even from several places at once: it sends a request only when the channel isn't loaded yet, or when you pass options it wasn't watched with. So an effect that runs twice, or two components opening the same channel, still send one query. It resolves with the channel to use from then on. Pass what you passed as channelQueryOptions to Channel.watch(options).

Channels that come from ChannelList, or from a client.queryChannelsAndHydrate() call, are already watched. If yours come from there, there is nothing to change. Channels from search results aren't watched. watch and query covers the ways to load a channel.

Two things to plan for:

  • A channel that isn't loaded renders as an empty channel, with no error, because nothing failed. If a channel shows nothing, check channel.initialized first.
  • Loading and error UI is yours. Channel no longer renders LoadingIndicator or LoadingErrorIndicator for a query it doesn't make. Render them around Channel, in your own layout. The LoadingErrorIndicator override key is removed, because no component renders it; the LoadingErrorIndicator component is still exported.

Channel no longer writes document.title

Channel used to add the channel's unread count to the tab title when a message arrived while the tab was hidden, and activeUnreadHandler let you change that. Both are removed, with no replacement in the SDK.

What belongs in a tab title depends on what your app shows, which no SDK component knows. It knows the unread counts, but not whether the user is looking at the channel they're in. The version that shipped counted only the open channel, did nothing in the threads view, skipped thread replies, and stacked prefixes across channel switches.

Two things to know when you write your own:

  • The counts belong to the user, not to a view. The total of unread messages comes from the connection's me payload and the total_unread_count on events. The number of unread threads comes from client.threads. Neither needs an open channel or thread.
  • They count different things. One counts unread messages in channels, the other counts threads with unread replies. Don't add them together.

ChannelProps.channel is required; EmptyPlaceholder is gone

A Channel without a channel had nothing to provide, so the prop is required and EmptyPlaceholder is removed. Check for a channel where you render it, or render ChannelPlaceholder, the channel's container with no channel in it, in the same place:

import type { Channel as StreamChannel } from "stream-chat";
import {
  Channel,
  ChannelPlaceholder,
  MessageComposer,
  MessageList,
} from "stream-chat-react";

export const ChannelPanel = ({ channel }: { channel?: StreamChannel }) =>
  channel ? (
    <Channel channel={channel}>
      <MessageList />
      <MessageComposer />
    </Channel>
  ) : (
    <ChannelPlaceholder>Select a conversation</ChannelPlaceholder>
  );

Window is gone

Window wrapped the channel's header, message list and composer, and made room for an open thread. It's removed. Render those parts directly inside Channel, and render the thread where your layout wants it: in a ThreadSlot with ChatView, or in your own panel with Thread. Channel UI shows a channel column.

Channel state is one store

In stream-chat 9, channel.state was a plain object. Its fields, such as read and members, changed without notifying anyone, and components re-rendered because Channel copied them into ChannelStateContext on every event. In stream-chat 10, channel.state is a store, and nothing copies it. Subscribe to it with useStateStore.

In v14:

const { read } = channel.state;

In v15:

import type { ChannelStateData } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react";

const readSelector = (state: ChannelStateData) => ({ read: state.read });

export const ReadCount = () => {
  const channel = useChannel();
  const { read } = useStateStore(channel.state, readSelector);
  return <span>{Object.keys(read).length} members have read state</span>;
};

Most keys keep their v9 names, such as members, membership, read, typing and watchers. watcher_count is watcherCount. The messages and the pinned messages moved to channel.messagePaginator and channel.pinnedMessagesPaginator. The state also holds values that used to live elsewhere: data, memberCount, muteStatus, watchStatus, initialized and aiState. Getters such as channel.state.members still work, but they return a snapshot and re-render nothing. What channels and threads hold lists the fields.

Two more renames:

  • channel.disconnected is channel.pendingDisposal, which is read-only.
  • Channel.getConfig() is the channel.serverConfig getter: drop the parentheses. It's the channel type's settings from the server. What the client enforces is channel.config, the server's settings combined with yours, which the channel.configState store also publishes.

The configuration a v14 component read from ChannelStateContext as channelConfig is channel.configState, with new field names:

v14 channelConfig v15 channel.configState
typing_events typingEvents.enabled
read_events readEvents.enabled
replies replies.enabled
user_message_reminders userMessageReminders.enabled
commands availableCommands

AIStates moved too: import it from stream-chat, not stream-chat-react.

The channel's latest message

channel.state.latestMessages and Channel.lastMessage() are removed. The channel's newest message is in its message paginator's aggregateState, whichever interval of messages is on screen.

In v14:

const last = channel.state.latestMessages.at(-1);

In v15:

import type { MessagePaginatorAggregateState } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react";

const latestMessageSelector = (state: MessagePaginatorAggregateState) => ({
  latestMessage: state.latestMessage,
});

export const LastMessageText = () => {
  const channel = useChannel();
  const { latestMessage } = useStateStore(
    channel.messagePaginator.aggregateState,
    latestMessageSelector,
  );
  return <span>{latestMessage?.text}</span>;
};

ChatContext.latestMessageDatesByChannels is removed too. It held when the current user last posted in each channel, for the slow-mode cooldown, which is now channel.cooldownTimer. useCooldownRemaining() returns the seconds left, and useIsCooldownActive() whether the user is still waiting.

ChatContext has no active channel

ChatContext no longer carries channel, setActiveChannel or channelsQueryState. Several Channel components can be mounted at once, each bound through its channel prop, so there is no single selected channel. Each Channel activates its own channel with Channel.activate(), and several can be active at once; see Entity lifecycle.

In v14:

const { setActiveChannel } = useChatContext();
setActiveChannel(nextChannel);

In v15:

import type { Channel as StreamChannel } from "stream-chat";
import { useWorkspaceNavigation } from "stream-chat-react";

export const OpenChannelButton = ({ channel }: { channel: StreamChannel }) => {
  const { openChannel } = useWorkspaceNavigation();
  return <button onClick={() => openChannel(channel)}>Open</button>;
};

useWorkspaceNavigation() also has isChannelActive(cid), openChannels, and the same for threads. Without a layout such as ChatView, it does nothing, so a component can call it anywhere. The first call of each open, close and back operation then logs a console warning. An app without a layout keeps the selected channel in its own state and passes it to Channel. See Navigation.

To show a channel from elsewhere, such as a deep link or a search result, in the channel lists, call client.channelManager.ingestChannel(channel) before you open it.

Channel lists

ChannelList no longer queries channels. Its data comes from a ChannelPaginator that you create and register on client.channelManager, and the component renders what that paginator holds. Its props are paginator, loadMoreDebounceMs and loadMoreThresholdPx.

In v14:

<ChannelList
  filters={filters}
  sort={{ last_message_at: -1 }}
  options={{ presence: true, state: true }}
/>

In v15:

import { useEffect, useMemo } from "react";
import { ChannelPaginator } from "stream-chat";
import type { ChannelFilters, SortParamRequest } from "stream-chat";
import { ChannelList, useChatContext } from "stream-chat-react";

const sort: SortParamRequest[] = [{ direction: -1, field: "last_message_at" }];

export const MyChannelList = ({ userId }: { userId: string }) => {
  const { client } = useChatContext();

  const paginator = useMemo(() => {
    const filters: ChannelFilters = {
      members: { $in: [userId] },
      type: "messaging",
    };
    return new ChannelPaginator({
      client,
      filters,
      id: "channels:default",
      paginatorOptions: { pageSize: 10 },
      requestOptions: { presence: true, state: true },
      sort,
    });
  }, [client, userId]);

  useEffect(() => {
    client.channelManager.insertPaginator({ paginator });
    return () => {
      client.channelManager.removePaginator(paginator);
    };
  }, [client, paginator]);

  return <ChannelList paginator={paginator} />;
};

Registering matters. client.channelManager routes events into the lists it holds. A paginator that's only passed as a prop shows its first page, and then never adds, moves or removes a channel as events arrive.

The query options split in two. Options of the request, such as presence, state and watch, go in requestOptions. Paging, such as pageSize, goes in paginatorOptions. offset and limit aren't accepted: the paginator owns them.

ChannelNavigation renders one ChannelList for each paginator on client.channelManager, with the list header and the search bar. Channel lists shows a sidebar built that way.

Props with no direct replacement

v14 prop v15
filters, sort, options the filters, sort and requestOptions of ChannelPaginator
channelRenderFilterFn, renderChannels, lockChannelOrder the paginator's filters and sort. The order comes from the sort, so a list can't be re-ordered locally
onMessageNew, onChannelUpdated, onAddedToChannel and the other on* props client.channelManager.addEventHandler(...). See Customizing event handling
allowNewMessagesFromUnfilteredChannels the ignoreEventsForUnknownChannels handler from stream-chat, inserted first. See Channels the client hasn't seen
customQueryChannels removed. The paginator sends the query
Paginator, EmptyStateIndicator the EmptyListIndicator, FirstPageLoadingIndicator, LoadingNextPageIndicator and EndReachedIndicator overrides on WithComponents
getLatestMessagePreview the SummarizedMessagePreview override
showChannelSearch render ChannelNavigation, which includes the search bar, or Search where your layout needs it
setActiveChannelOnMount, customActiveChannel open a channel with useWorkspaceNavigation().openChannel(channel)
sendChannelsToList read the paginator's channels with useChannelPaginatorState(paginator)
recoveryThrottleIntervalMs removed. The client reloads the registered lists after a reconnect
watchers removed

The channel-list helpers moveChannelUpwards, isChannelPinned, isChannelArchived, findLastPinnedChannelIndex, extractSortValue, shouldConsiderPinnedChannels and shouldConsiderArchivedChannels are removed, along with usePaginatedChannels. A list no longer moves a channel to the top when a message arrives: a channel's place comes from the sort, so sort by last_message_at to keep the latest activity first.

More than one list

Register several paginators, and let the manager decide which list holds a channel that matches more than one:

client.channelManager.setPaginators([pinned, archived, muted, everythingElse]);
client.channelManager.setOwnershipResolver([
  "channels:archived",
  "channels:muted",
]);

client.channelManager.setPaginators() publishes one update for the whole set. Use client.channelManager.insertPaginator() and client.channelManager.removePaginator() for changes one at a time. Channels that match several paginators explains the ownership rules.

ChannelListContext holds a paginator

channels, setChannels, hasNextPage and loadNextPage are gone. The context gives a ChannelPaginator, and useChannelPaginatorState reads it. ChannelLists, which ChannelNavigation renders, provides the first paginator registered on client.channelManager. A ChannelList you render yourself doesn't provide it: wrap it in ChannelListContextProvider.

In v14:

const { channels, setChannels } = useChannelListContext();

In v15:

import type { ChannelPaginator } from "stream-chat";
import {
  useChannelListContext,
  useChannelPaginatorState,
} from "stream-chat-react";

const LoadedChannelCount = ({ paginator }: { paginator: ChannelPaginator }) => {
  const { channels } = useChannelPaginatorState(paginator);
  return <span>{channels?.length ?? 0} channels</span>;
};

export const ChannelCount = () => {
  const { paginator } = useChannelListContext();
  return paginator ? <LoadedChannelCount paginator={paginator} /> : null;
};

To change the loaded list, call ChannelPaginator.setItems({ valueOrFactory }). To load the next page, call ChannelPaginator.toTail().

Channel-list UI overrides

ChannelListUI is removed. The list body and its states are separate overrides on WithComponents: ListItem, EmptyListIndicator, FirstPageLoadingIndicator, LoadingNextPageIndicator and EndReachedIndicator. SummarizedMessagePreview renders a row's message preview. See Channel list UI.

ChannelListItem lost channelUpdateCount, getLatestMessagePreview, setActiveChannel, watchers and its own key prop. React's key is unaffected. Pass previewedMessage to preview a message other than the latest, such as a search match. ChannelListItemUI takes previewedMessage instead of lastMessage and latestMessagePreview. When the channel type skips system messages for the last message, the preview skips them too, so it agrees with the list's order.

Threads

Thread takes a thread and renders its children

Thread no longer renders a header, a message list and a composer of its own. Like Channel, it takes the thread as a required thread prop, gives it to its children, loads it when needed, and renders the children you pass. You no longer wrap it in ThreadProvider.

In v14:

<Thread
  additionalMessageComposerProps={{ audioRecordingEnabled: true }}
  additionalMessageListProps={{ onUserClick }}
  virtualized
/>

In v15:

import type { Thread as StreamThread } from "stream-chat";
import {
  MessageComposer,
  Thread,
  ThreadHeader,
  VirtualizedMessageList,
} from "stream-chat-react";

export const ThreadPanel = ({ thread }: { thread: StreamThread }) => (
  <Thread thread={thread}>
    <ThreadHeader />
    <VirtualizedMessageList />
    <MessageComposer audioRecordingEnabled focus />
  </Thread>
);

Get the thread from client.threads.ensure({ channel, parentMessage }) or client.threads.get(id). With ChatView, ThreadSlot renders Thread for the thread in its slot, and its children are the thread's contents. Thread UI covers both.

Where the removed props went:

v14 prop on Thread v15
additionalMessageComposerProps props on your MessageComposer
additionalMessageListProps props on your MessageList
additionalVirtualizedMessageListProps props on your VirtualizedMessageList
additionalParentMessageProps a ThreadHead override on WithComponents
autoFocus <MessageComposer focus />
virtualized render the list you want
enableDateSeparator removed. Pass withDateSeparator to the list
Message a MessageUI override, set with WithComponents around the thread
messageActions removed. See Message actions

What follows from it:

  • Date separators. v14's Thread turned them off for the list it rendered. A MessageList in a thread now shows them, unless you pass withDateSeparator={false}.
  • The parent message is rendered by the message list, above the replies, because it scrolls with them. The head prop of MessageList and VirtualizedMessageList is removed. To change how the parent renders, set a ThreadHead override.
  • The header. ComponentContext.ThreadHeader is removed: render the header you want. ThreadHeader takes overrideTitle, StartContent and EndContent. Its thread and closeThread props are removed. It closes the thread through workspace navigation, and useCloseThread() gives a custom header the same behaviour.
  • No remount per thread. Thread no longer rebuilds its children when the thread changes. A component inside it that reset its state on mount must depend on the thread instead.
  • useLegacyThreadContext is removed. The thread's composer reads the thread from ThreadContext.

Layout

ChatView is a slot layout

ChatView moved to stream-chat-react/slot-layout, and its compound components are gone. ChatView.Channels and ChatView.Threads are replaced by the views prop, and ChatView.ThreadAdapter by ThreadSlot. layouts declares the slots of each view. useThreadsViewContext is removed.

In v14:

<ChatView>
  <ChatView.Selector />
  <ChatView.Channels>
    <ChannelList filters={filters} />
    <Channel>
      <Window>
        <MessageList />
        <MessageComposer />
      </Window>
      <Thread />
    </Channel>
  </ChatView.Channels>
  <ChatView.Threads>
    <ThreadList />
    <ChatView.ThreadAdapter>
      <Thread />
    </ChatView.ThreadAdapter>
  </ChatView.Threads>
</ChatView>

In v15:

import {
  ChannelHeader,
  ChannelNavigation,
  MessageComposer,
  MessageList,
  ThreadHeader,
  ThreadList,
} from "stream-chat-react";
import {
  ChannelSlot,
  ChatView,
  ThreadSlot,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";

// Declared outside the component, so ChatView gets the same array on every render.
const layouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["channel", "thread"] },
  { id: "threads", slots: ["thread"] },
];

const ThreadPanel = () => (
  <ThreadSlot slot="thread">
    <ThreadHeader />
    <MessageList />
    <MessageComposer />
  </ThreadSlot>
);

const ChannelsView = () => (
  <>
    <ChatView.Selector />
    <ChannelNavigation />
    <ChannelSlot slot="channel">
      <ChannelHeader />
      <MessageList />
      <MessageComposer />
    </ChannelSlot>
    <ThreadPanel />
  </>
);

const ThreadsView = () => (
  <>
    <ChatView.Selector />
    <ThreadList />
    <ThreadPanel />
  </>
);

export const Workspace = () => (
  <ChatView
    layouts={layouts}
    views={{ channels: <ChannelsView />, threads: <ThreadsView /> }}
  />
);

Pass layouts with views. Without it, each view has a single slot that holds its list, and channels and threads have nowhere to open. Workspace with ChatView explains views, slots and where an opened channel lands.

ChatView.Selector, useActiveThread and useChatViewContext are imported from stream-chat-react/slot-layout too. useChatViewContext throws outside ChatView.

New package entry points

The layout parts have their own entry points, so an app that doesn't use them doesn't load them:

Import from What it holds
stream-chat-react useWorkspaceNavigation, ChannelNavigation and everything else in the core
stream-chat-react/slot-layout ChatView and its selector, WorkspaceLayout, Slot, ChannelSlot, ThreadSlot, ThreadListSlot, useActiveThread, useChatViewContext and useChatViewNavigation
stream-chat-react/slot-geometry SlotGeometryProvider, useSlotGeometry and useRegisterSlotGeometry

The stylesheets didn't move: stream-chat-react/dist/css/index.css and the other files in dist/css/ are the same as in v14, and index.css styles the slot layout too. Entry points and CSS files lists every entry point.

Components

Removed contexts

ChannelStateContext and ChannelActionContext are removed, along with useChannelStateContext, useChannelActionContext and their providers. So are TypingContext, useTypingContext and TypingProvider. Channel state is no longer copied into React context. Read it from the channel.

In v14:

const { channel, messages, members } = useChannelStateContext();

In v15:

import type { ChannelStateData, MessagePaginatorState } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react";

const itemsSelector = (state: MessagePaginatorState) => ({
  messages: state.items,
});
const membersSelector = (state: ChannelStateData) => ({
  members: state.members,
});

export const ChannelSummary = () => {
  const channel = useChannel();
  const { messages } = useStateStore(
    channel.messagePaginator.state,
    itemsSelector,
  );
  const { members } = useStateStore(channel.state, membersSelector);

  return (
    <span>
      {messages?.length ?? 0} messages loaded, {Object.keys(members).length}{" "}
      members
    </span>
  );
};

useChannel() takes no arguments. It returns the channel of the surrounding Channel, or the thread's channel inside a Thread, and throws outside both. Inside a thread, channel.messagePaginator is the channel's list, not the thread's: use useMessagePaginator(), which returns the thread's list there. Which owner a hook reads shows how to pick the right one.

Where the common v14 values are:

v14 v15
channel useChannel()
messages items in channel.messagePaginator.state
hasMore, hasMoreNewer, loadingMore hasMoreTail (older), hasMoreHead (newer) and isLoading in the same state
members, read, watchers, watcher_count members, read, watchers and watcherCount in channel.state
typing, from useTypingContext() typing in channel.state. See Typing indicators
channelCapabilities useChannelCapabilities()
channelConfig channel.configState. See Channel state is one store
pinnedMessages channel.pinnedMessagesPaginator
thread, threadMessages, openThread, closeThread useThreadContext(), thread.messagePaginator, and openThread and closeThread from useWorkspaceNavigation()
loadMore, loadMoreNewer MessagePaginator.toTail() and MessagePaginator.toHead()
jumpToMessage(id), jumpToLatestMessage() MessagePaginator.jumpToMessage(id) and MessagePaginator.jumpToTheLatestMessage()
sendMessage, editMessage, retrySendMessage, deleteMessage MessageComposer.send() and MessageComposer.update(), and channel.messageOperations. See Message operations
markRead Channel.markReadViaReporter()
mutes, from MessageContext mutes from useChatContext()

Context hooks take no component name, and throw outside their provider

The optional componentName argument is removed from useChatContext, useTranslationContext, useMessageContext, useComponentContext, useMessageComposerContext, useMessageListContext and useMessageBounceContext. Write useChatContext(), not useChatContext("MyComponent"). TypeScript flags every call. Plain JavaScript ignores the argument.

In v14 most context hooks returned an empty object outside their provider, some with a warning in the console. In v15 a hook whose provider is required throws, and the error names the provider it needs. This affects, among others, useChatContext outside Chat, useMessageContext outside a message, useMessageComposerContext outside a composer, and useChatViewContext outside ChatView. A component that renders both inside and outside a channel reads useChannelInstanceContext(), which returns {} outside a Channel. useTranslationContext and useComponentContext still work anywhere.

Message UI overrides use MessageUI

The deprecated Message override key is removed, and so is every Message prop that took a message UI component. Set the MessageUI override:

v14 v15
Message key on WithComponents MessageUI
<MessageList Message={CustomMessage} /> the MessageUI override
<VirtualizedMessageList Message={CustomMessage} /> the VirtualMessage override, which falls back to MessageUI
<Message Message={...} />, <Thread Message={...} /> the MessageUI override, set around the part it applies to
import type { PropsWithChildren } from "react";
import {
  MessageText,
  WithComponents,
  useMessageContext,
} from "stream-chat-react";

const CustomMessage = () => {
  const { message } = useMessageContext();
  return (
    <div className="custom-message">
      <strong>{message.user?.name}</strong>
      <MessageText />
    </div>
  );
};

// Declared outside the component, so WithComponents gets the same object on every render.
const overrides = { MessageUI: CustomMessage };

export const WithCustomMessages = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);

A custom message UI receives no props: it reads everything from useMessageContext(). In TypeScript, a leftover Message key or prop is a compile error, and for the key the error suggests MessageUI. In plain JavaScript it's ignored, and the default message UI renders, so search your code for Message= and Message:. See Overriding components.

Date separators: withDateSeparator

MessageList and VirtualizedMessageList took disableDateSeparator, while Thread took enableDateSeparator. Both lists take withDateSeparator now, and Thread's prop is removed. The defaults behave as before:

Component v14 v15
MessageList disableDateSeparator={false} withDateSeparator={true}
VirtualizedMessageList disableDateSeparator={true} withDateSeparator={false}

The helpers took the rename too: processMessages takes withDateSeparator instead of enableDateSeparator, and useEnrichedMessages instead of disableDateSeparator.

Message list props

Besides Message, head and the date separator prop, the lists dropped the props that Channel used to fill in. MessageList dropped loadMore, loadMoreNewer, loadingMoreForJumpToChannelMessage, openThread, retrySendMessage and threadList. VirtualizedMessageList dropped loadMore, loadMoreNewer, openThread and threadList, and also hasMore, hasMoreNewer, loadingMore, loadingMoreNewer, highlightedMessageId, channelUnreadUiState and messageLimit. Each list reads its message paginator, and knows from its context whether it shows a thread. MessageList also dropped hasMore, jumpToLatestMessage, loadingMoreNewer, messageLimit and messages, and VirtualizedMessageList dropped messages: each list renders the messages of its paginator. MessageList keeps loadingMore. Without it, the list shows the LoadingIndicator override while it loads an older page; loadingMore={false} hides it.

Message actions

The messageActions prop is removed from MessageList, VirtualizedMessageList, Thread and Message, together with MESSAGE_ACTIONS, OPTIONAL_MESSAGE_ACTIONS, getMessageActions(), and the MessageActionsArray and Capabilities types. In v14 the prop had already stopped changing which actions showed. MessageContext no longer has getMessageActions or actionsEnabled.

To choose the actions, filter defaultMessageActionSet and pass the result to MessageActions:

import type { PropsWithChildren } from "react";
import {
  MessageActions,
  WithComponents,
  defaultMessageActionSet,
} from "stream-chat-react";

// The dropdown toggle has no type, so keep it by its placement.
const actionSet = defaultMessageActionSet.filter(
  (item) =>
    item.placement === "quick-dropdown-toggle" || item.type !== "delete",
);

const MessageActionsWithoutDelete = () => (
  <MessageActions messageActionSet={actionSet} />
);

const overrides = { MessageActions: MessageActionsWithoutDelete };

export const WithoutDelete = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);

The action types the SDK ships are the DefaultMessageActionType union. Actions the user can't perform are still left out for you. See Actions.

Composer

  • overrideSubmitHandler is removed from MessageComposer. To change the request, register a sendMessageRequest handler, as in Channel props that moved. To change the message before it's sent, add a composition middleware.
  • preventClearingOnUnmount is removed. MessageComposer no longer clears a composer you supplied through MessageComposerControllerProvider when it unmounts. Call MessageComposer.clear() on it yourself when you're done with it.
  • MessageComposerContext no longer has handleSubmit. A custom submit control calls the function useMessageComposerSubmitFn() returns, which sends a new message or saves an edit. To send from your own code, call MessageComposer.send(), or MessageComposer.update() for an edit. Each resolves to "sent", "nothing-to-send" or "failed".
  • useAttachmentManagerState() returns customCdn instead of hasCustomDoUploadRequest. If your upload function sends files to storage Stream doesn't host, declare it with client.config.set({ messageComposer: { attachments: { customCdn: true } } }), or the file action disappears for users without the upload-file capability.
  • UnsupportedAttachmentPreview and RemoveAttachmentPreviewButton are exported, for custom attachment previews to reuse.

Headers

ComponentContext.HeaderStartContent is removed. ChannelHeader and ThreadHeader take StartContent and EndContent props instead. By default, StartContent is a back button when the panel is stacked over other content, and EndContent is a close button when the panel can be closed:

import {
  ChannelHeader,
  WorkspaceNavigationBackButton,
} from "stream-chat-react";

// SidebarToggle stands for a control of your own.
const SidebarToggle = () => <button type="button">Menu</button>;

const HeaderStart = () => (
  <>
    <WorkspaceNavigationBackButton />
    <SidebarToggle />
  </>
);

export const Header = () => <ChannelHeader StartContent={HeaderStart} />;

A prop replaces its default button, so render the button too if you want to keep it. HeaderEndContent stays on ComponentContext, for the end of the channel-list and thread-list headers.

Audio playback

allowConcurrentAudioPlayback is removed from Channel, and allowConcurrentPlayback from WithAudioPlayback. One voice message plays at a time across the app: starting one pauses the other. Switching channels stops the previous channel's audio. useActiveAudioPlayer() reports the player that's active anywhere in the app.

Channel search results, and the channels behind message search results, are no longer watched. The built-in result items watch the channel when the user opens it. A custom result item calls Channel.watch(), or MessagePaginator.jumpToMessage(id, { watchChannel: true }) for a message.

Channel no longer scrolls to a message picked in search. The result item calls MessagePaginator.jumpToMessage() itself, and the highlight is in channel.messagePaginator.messageFocusSignal. See Search.

Styling

The stylesheet paths and the --str-chat__* variables are the same as in v14. A few things changed around them:

  • Wrappers. .str-chat__main-panel, which Window rendered, and the channel's inner .str-chat__container are gone, and customClasses.chatContainer is removed with them. The header, the message list and the composer sit directly in .str-chat__channel. Move CSS that targets either wrapper to .str-chat__channel.
  • The channel list inside ChatView. Inside ChatView, the channel list renders no str-chat element of its own and takes the tokens from ChatView's root, so a token you override there reaches the list. CSS that matched the list's root as .str-chat inside ChatView, such as .str-chat.str-chat__channel-list, no longer matches it. Outside ChatView, ChannelNavigation's root, or a wrapper around a ChannelList rendered on its own, carries str-chat and the theme classes. customClasses.channelList replaces str-chat__channel-list on ChannelNavigation's root. See Design tokens.
  • Quoted messages. The quoted message preview, in the composer and inside messages, uses the text colour that goes with its background, --str-chat__chat-text-incoming, or --str-chat__chat-text-outgoing for your own messages. If you overrode those colours to fix the contrast, remove the override.
  • Buttons in dark mode. The dark theme sets its own button text colours: dark text on solid primary and destructive buttons, and lighter text on outline and ghost buttons.

Theming lists the variables.