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 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 stream-chat-react.
  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.

AI-assisted migration

If you have Stream's agent skills installed, the quickest path is the /stream-react skill. Point it at your project and let it run the upgrade:

/stream-react Upgrade stream-chat-react to v15 and stream-chat to v10 in this project.

To drive the migration yourself, or with another AI assistant, use a prompt like this:

Migrate this app from stream-chat-react v14 to v15, and from stream-chat v9 to v10.

Use the installed packages in node_modules as the source of truth.
Before using any symbol or prop, verify it in stream-chat-react/dist/types and stream-chat/dist/types.
Use these migration guides as reference:
- https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/ai-migration-v14-v15.md
- https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/v15-breaking-changes.md
- https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/i18n-v15-migration.md
- https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md
- https://getstream.io/chat/docs/sdk/react/release-guides/upgrade-to-v15/

Do the migration in phases:
1. packages and client setup: upgrade both packages, move client options into config
2. data shapes: custom fields under custom, nanosecond timestamps, sort arrays
3. channels and lists: ChannelPaginator for every ChannelList, Channel.watch() before Channel, remove Window, pass thread to Thread
4. contexts and navigation: replace the removed channel contexts and setActiveChannel, move ChatView to stream-chat-react/slot-layout
5. request handlers, translation keys, and renamed component props
6. CSS and runtime behavior audit

Stop after each phase and report the exact changes and remaining errors.

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.

The defaultLanguage prop of <Chat/> 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, 661 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.

One key changed its values

Renaming the key isn't enough for the two-person typing text. v14 passed both names as one typing value, already joined with an English "and", so a translation couldn't change that word. In v15, typing.twoUsers gets the names as firstUser and secondUser, and the translation writes the whole sentence. The English default is {{ firstUser }} and {{ secondUser }} are typing.

// v14
i18n.registerTranslation("de", {
  "{{ typing }} are typing": "{{ typing }} schreiben",
});
// v15
i18n.registerTranslation("de", {
  "typing.twoUsers": "{{ firstUser }} und {{ secondUser }} schreiben",
});

TypeScript doesn't catch this one: a translation that keeps {{ typing }} still compiles, but it shows the literal text {{ typing }}, because nothing passes that value any more. Your own t("typing.twoUsers", { typing }) call no longer compiles: the key needs its English text. In JavaScript it shows the key itself, "typing.twoUsers", because the SDK registers no English text for this key. Pass firstUser, secondUser and the English text: t("typing.twoUsers", "{{ firstUser }} and {{ secondUser }} are typing", { firstUser, secondUser }). typing.singleUser ({{ typing }}) and typing.manyUsers ({{ count }}) are unchanged. The texts in Typing indicators lists all three keys.

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.

When your app imports stream-chat-react, channel.data.custom.name and .image are typed string | undefined. Delete your own name and image declarations on CustomChannelData, and keep the augmentation for your other fields. Redeclaring name or image with another type wins silently with skipLibCheck: true in your tsconfig, and fails with TS2430 without it. If your name or image has another type, rename that field.

DefaultChannelData holds only name and image; subtitle and team are removed. If you store them under custom, declare them on CustomChannelData. A channel's team is the top-level channel.data.team.

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. headerPosition on <MessageList/> is a TimestampNS, and so are lastRead in processMessages and lastReadDate in the render props of <VirtualizedMessageList/>. 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.
  • capabilities.changed. The client no longer dispatches it, and a listener for it doesn't compile. Subscribe to ownCapabilities in channel.state instead: channel.state.subscribeWithSelector(({ ownCapabilities }) => ({ ownCapabilities }), handler). It updates on every change to the channel's data, and calls its subscribers only when the capabilities really change. In components, useChannelCapabilities({ cid }) returns them as a Set.
  • Command names. A command's name and set are plain strings. stream-chat no longer exports CustomCommandData, and stream-chat-react no longer exports DefaultCommandData. Delete your uses of both. For a closed set of names, declare a union of your own, such as type AppCommandName = "giphy" | "poll", and check command.name against it.
  • 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.
  • Uploading an attachment. AttachmentManager.uploadAttachment() is removed. To retry a failed upload, or to upload an attachment you built yourself, such as a voice recording, call AttachmentManager.uploadFile(attachment) instead, after checking attachmentManager.isUploadEnabled, which AttachmentManager.uploadFile() doesn't check. It runs your upload middleware on the retry, and always resolves to the attachment: where you checked the result for undefined, read localMetadata.uploadState. See Retrying a failed upload.
  • Attachments with nothing to upload. An attachment of your own, such as a product card, needs only localMetadata: { id }. The composer sends it as it is, also after an edit or from a draft, and it doesn't count toward the file limit (messageComposer.attachments.maxNumberOfFilesPerMessage in client.config). Drop the empty File and the uploadState: 'finished' you set on it, and any middleware that put it back into an edited message or a draft. localMetadata with a file must have an uploadState too. Both are optional now, so code that reads localMetadata.file or localMetadata.uploadState must handle undefined. See Adding and removing attachments.
  • 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. 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/> and <VirtualizedMessageList/>, called with (event, message). See Message text and mentions
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.

Every <ChannelList/> is named "Channel list" for screen readers. To give each list its own name, render the lists yourself and pass listboxProps, as Several lists shows.

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, ListLoadErrorIndicator, LoadingNextPageIndicator and EndReachedIndicator. <SummarizedMessagePreview/> renders a row's message preview. See Channel list UI.

<ChannelListItem/> lost activeChannel, channelUpdateCount, getLatestMessagePreview, setActiveChannel, watchers and its own key prop. React's key is unaffected. v14 only forwarded activeChannel to <ChannelListItemUI/>, so a custom <ChannelListItemUI/> that compared it with its channel reads active instead. By default, active is true while the channel is shown in the workspace. An app that keeps its own selected channel renders <ChannelListItem/> in a ListItem override and passes it active, comparing that channel with the row's channel. 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.

List load errors work differently:

v14 v15
A failed query rendered the LoadingErrorIndicator override in place of the list, which rendered nothing by default A first page that fails to load renders the ListLoadErrorIndicator override in place of the list. It gets error, isRetrying and retry, and the default has a "Try again" button.
A failed query published no notification <ChannelList/> publishes "Couldn't load conversations", of type api:channel:query:failed, in the channel-list panel, which <ChannelNavigation/> renders.

Move a channel-list LoadingErrorIndicator override to ListLoadErrorIndicator. The LoadingErrorIndicator key itself is removed, as Channel no longer queries the channel says.

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, and StartContent and EndContent as described in Headers. 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.

Thread list loading and errors

v14 v15
The first-load placeholder had no override key <ThreadList/> reads FirstPageLoadingIndicator, the key the channel list reads.
A failed first page left the thread list showing its empty placeholder <ThreadList/> renders ListLoadErrorIndicator, and publishes api:thread:query:failed in the thread-list panel.

The keys are shared with the channel list: Replacing a state in one list only shows how to scope an override to one list.

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 prop of <Message/> 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.

Override keys the SDK renders without props

These override keys are typed React.ComponentType without props: AttachmentPreviewList, MessageComposerUI, MessageEditedIndicator, MessageStatus, NotificationAnnouncer, QuotedMessagePreview, SearchSourceResultList, TextareaComposer and VoiceRecordingPreviewSlot. The SDK rendered them without props in v14 too, so an override never got the props its v14 type listed. Two things no longer compile:

  • an override whose props are required;
  • your own component rendering one of these keys from useComponentContext() with props.

To customize the default, render the exported component with its props inside the override. MessageComposerUI and TextareaComposer read the <MessageComposer/> props from useMessageComposerContext().

In v14:

const { MessageStatus } = useComponentContext();
<MessageStatus MessageReadStatus={ReadIcon} />;

In v15:

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

const ReadIcon = () => <span>read</span>;
const CustomMessageStatus = () => (
  <MessageStatus MessageReadStatus={ReadIcon} />
);

const overrides = { MessageStatus: CustomMessageStatus };

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

Other override keys changed type too, without breaking an override that worked:

  • ReactionSelector; see Reactions.
  • SearchSourceResults receives { searchSource }.
  • MessageListItem receives MessageListItemProps: className, data-index, data-message-id and data-testid. MessageListWrapper receives MessageListWrapperProps: className. Spread them onto the element your override renders, as Message list UI explains.

MessageText is a new key. It renders a message's text, and <StreamedMessageText/> renders the text of an AI-generated message through it while it streams.

Overrides that now apply in more places

v14 rendered the default component in some places, whatever the override. An override registered for v14 now shows there too:

Key New places
FileDragAndDropContent the overlay of <WithDragAndDropUpload/>
BaseImage link previews in messages and in the composer, video thumbnails, the full-screen gallery, the thumbnails of quoted messages in the list and the composer, and the thumbnail of the edited-message preview
LoadingIndicator <LoadMoreButton/>, the image grid of a message (<ModalGallery/>) while its thumbnails load, the new-threads banner of the thread list, poll answer and vote lists, and the channel detail lists
Timestamp thread list items and user search results
Avatar the latest reply's author in thread list items
DateSeparator the date above a thread's parent message, which also gets the list's formatDate
Gallery the gallery that composer attachment previews open, which also gets onRequestClose
FileSizeIndicator the files list in channel details

Thread list items also render the SummarizedMessagePreview key, so an override written for the channel list shows there too.

Two places keep the default. The tiles of <GroupAvatar/> render the default Avatar; see Avatar. The channel list's next-page spinner renders the default LoadingIndicator: replace LoadingNextPageIndicator there.

To keep the default in one place, wrap that part in a <WithComponents/> with the exported default, such as <WithComponents overrides={{ LoadingIndicator }}> around a poll.

Date separators: withDateSeparator

<MessageList/> and <VirtualizedMessageList/> took disableDateSeparator, while <Thread/> took enableDateSeparator. Both lists take withDateSeparator now, and the prop of <Thread/> 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.

<VirtualizedMessageList/> dropped defaultItemHeight, overscan and scrollSeekPlaceHolder. Pass them to react-virtuoso through additionalVirtuosoProps:

v14 v15
defaultItemHeight additionalVirtuosoProps.defaultItemHeight
overscan additionalVirtuosoProps.overscan
scrollSeekPlaceHolder additionalVirtuosoProps.scrollSeekConfiguration and additionalVirtuosoProps.components.ScrollSeekPlaceholder
import { VirtualizedMessageList } from "stream-chat-react";

export const Messages = () => (
  <VirtualizedMessageList
    additionalVirtuosoProps={{ defaultItemHeight: 80, overscan: 200 }}
  />
);

additionalMessageComposerProps is removed from <MessageList/>, <VirtualizedMessageList/>, <Message/> and VirtuosoContext. No SDK component read it, so delete it. A custom ThreadHead override that reads it from its props no longer compiles, so delete it there too. If you meant those props for the composer, pass them to your <MessageComposer/> (v14 never applied them). A component in additionalVirtuosoProps.components that read it from VirtuosoContext needs the value from a context of your own.

<Message/> and MessageContext no longer have threadList. A custom message UI that read useMessageContext().threadList reads !!useThreadContext() instead, which is true inside a thread. ReactionSelector.getDialogId() takes only { messageId }.

MessageContext no longer has endOfGroup, firstOfGroup and groupedByUser; the lists never set them. A message's place in its group is groupStyles?.[0] from useMessageContext(): top, middle, bottom or single. It's undefined for a message no list groups, such as a standalone <Message/> or the parent message above a thread's replies.

The grouping props have new names on both lists:

v14 v15
<MessageList noGroupByUser /> <MessageList groupByUser={false} />
<VirtualizedMessageList shouldGroupByUser /> <VirtualizedMessageList groupByUser />
groupStyles={(message, previousMessage, nextMessage, noGroupByUser, maxTime) => …} getGroupStyles={({ message, previousMessage, nextMessage, groupByUser, maxTimeBetweenGroupedMessages }) => …}

The exported getGroupStyles takes the same object, so a function of yours can still hand the other messages to it. In plain JavaScript the old props are ignored without an error, so search for them.

showAvatar on <MessageList/>, <VirtualizedMessageList/> and <Message/> now reaches the message UI. In a thread, the parent message above the replies now gets the props the list passes to every message, such as renderText, showAvatar, onMentionsClick and onUserClick. It doesn't get returnAllReadData, messageListRect or autoscrollToBottom.

<VirtualizedMessageList/> now takes onMentionsClick, onMentionsHover, onUserClick, onUserHover and unsafeHTML, like <MessageList/>. It passes them to every message, to the thread's parent message, and to the Giphy preview that separateGiphyPreview renders. With customMessageRenderer, pass them to the <Message/> you render yourself. <VirtualizedMessageList/> also passes unsafeHTML to the MessageSystem override, as <MessageList/> already did.

GiphyPreviewMessageProps is now MessageProps. A GiphyPreviewMessage override that typed its props as { message } still works. Spread its props into <Message/> to keep the list's handlers.

Reactions

MessageReactions.reverse, MessageReactionsDetail.sort and MessageReactionsDetail.dialogId are removed. The default components ignored all three in v14, so delete them. The default <MessageUI/> no longer passes reverse to a MessageReactions override.

v14 v15
reverse on <MessageReactions/> sortReactions, on <MessageList/>, <VirtualizedMessageList/>, <Message/> or <MessageReactions/>, orders the reaction types
sort on <MessageReactionsDetail/> reactionDetailsSort, a SortParamRequest[] on the same components or on <MessageReactionsDetail/>, orders the users
dialogId on <MessageReactionsDetail/> MessageReactionsDetail.getDialogId({ messageId }) returns the dialog's id

The ReactionSelector override key is typed ComponentType<ReactionSelectorProps> instead of a forwardRef component, so a plain function component fits it. The SDK never passed it a ref. See Reaction components.

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.

disableQuotedMessages is removed from <MessageList/> and <Message/>. To hide Quote Reply, filter out the item whose type is "quote", the way the WithoutDelete sample filters out delete.

The thread's parent message: initialMessage is gone

initialMessage is removed from <Message/> and MessageContext, and the default <ThreadHead/> no longer sets it. To tell the thread's parent message from its replies, call useIsRenderedAsThreadParent() in a component that <Message/> renders. It returns true for the parent message of the thread in context, and false for replies and outside a thread.

// v14
const { initialMessage } = useMessageContext();
const isThreadReply = !!useThreadContext() && !initialMessage;
// v15
import {
  useIsRenderedAsThreadParent,
  useThreadContext,
} from "stream-chat-react";

export const useIsThreadReply = () => {
  const isThreadParent = useIsRenderedAsThreadParent();
  return !!useThreadContext() && !isThreadParent;
};

In plain JavaScript the removed field is undefined, so !!useThreadContext() && !initialMessage is true for the parent message too. Search for initialMessage.

In v14, a custom <ThreadHead/> that rendered <Message {...props} /> without initialMessage gave the parent message the full action menu. In v15 the default message actions detect the parent message themselves, so it shows no actions with either the default or a custom <ThreadHead/>.

Note:

To give the parent message actions again, override MessageActions with a component that renders the SDK's <MessageActions disableBaseMessageActionSetFilter /> while useIsRenderedAsThreadParent() returns true. That skips the base filter's capability checks too, so pass a messageActionSet with only the actions the user may perform. See The message action set.

Message permissions

useUserRole(message, disableQuotedMessages?) is renamed useMessagePermissions(message). It returns the same can* flags and isMyMessage. canQuote is the quote-message capability alone.

isAdmin, isModerator and isOwner are removed. Check what the user may do instead of their role: use a can* flag, or read useChannelCapabilities() for any other capability. useMessagePermissions lists the flags.

In v14:

const { canReply, isModerator } = useUserRole(message);

In v15:

import {
  useChannel,
  useChannelCapabilities,
  useMessageContext,
  useMessagePermissions,
} from "stream-chat-react";

export const ModerationTools = () => {
  const channel = useChannel();
  const { message } = useMessageContext();
  const { canReply } = useMessagePermissions(message);
  const capabilities = useChannelCapabilities({ cid: channel.cid });
  const canBan = capabilities.has("ban-channel-members");

  return (
    <>
      {canReply && <button type="button">Reply</button>}
      {canBan && <button type="button">Ban</button>}
    </>
  );
};

Message text and mentions

The mentionedUsers arguments are removed. Mentions travel as RenderTextMentionEntity objects, which cover users, @channel, @here, roles and user groups. getRenderTextMentionEntities(message) builds them from a message.

v14 v15
renderText(text, mentionedUsers, options) renderText(text, options), with the mentions in options.messageMentionEntities
RenderTextFunction: (text, mentionedUsers, options) (text, options)
node.mentionedUser in a custom mention renderer node.mentionedEntity. For a user, mentionedEntity.mentionType is "user"
mentionsMarkdownPlugin(users) mentionsMarkdownPlugin(getRenderTextMentionEntities(message))
createMentionPluginAndDisplayTextSet(users) createMentionPluginAndDisplayTextSet(entities)
onMentionsClick(event, mentionedUsers, message), same for hover onMentionsClick(event, message). Read the clicked mention's data-user-id, data-mention-id or data-mention-type
MentionedUserEventHandler CustomMentionHandler

A custom renderText still written for the v14 order doesn't compile. In JavaScript, the options it adds in the third argument, such as customMarkDownRenderers, are ignored. renderText function lists the options.

In v14:

const customRenderText: RenderTextFunction = (text, mentionedUsers, options) =>
  renderText(text, mentionedUsers, { ...options, customMarkDownRenderers });

In v15:

import type { RenderTextFunction, RenderTextOptions } from "stream-chat-react";
import { renderText } from "stream-chat-react";

declare const customMarkDownRenderers: RenderTextOptions["customMarkDownRenderers"];

const customRenderText: RenderTextFunction = (text, options) =>
  renderText(text, { ...options, customMarkDownRenderers });

Mentions are clickable only with onMentionsClick. Then each @name in the text gets cursor: pointer, and the message text can be reached with Tab: pressing Enter or Space on it calls the handler, with the text as the target. Without it, mentions are highlighted but not clickable. onMentionsHover runs either way. Reacting to clicks and hovers shows a handler that reads the clicked mention.

MessageContextValue.onMentionsClickMessage and MessageContextValue.onMentionsHoverMessage are undefined without a handler, and so are the handlers useMentionsHandler() returns. A custom message UI that calls them needs ?., or passes them as event props:

import {
  getRenderTextMentionEntities,
  renderText,
  useMessageContext,
} from "stream-chat-react";

export const PlainMessageText = () => {
  const { message, onMentionsClickMessage } = useMessageContext();

  return (
    <div
      onClick={onMentionsClickMessage}
      onKeyDown={(event) => {
        if (event.key === "Enter") onMentionsClickMessage?.(event);
      }}
      tabIndex={onMentionsClickMessage ? 0 : undefined}
    >
      {renderText(message.text, {
        messageMentionEntities: getRenderTextMentionEntities(message),
      })}
    </div>
  );
};

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.
  • stream-chat installs the four command middlewares in every composer, as The command middlewares explains. Delete your own installs of them, usually insert() calls in the composer setup function, as the v14 examples had. A command picked from the Commands menu is now sent with its name, /giphy cats rather than cats. A composer of your own that doesn't render <CommandChip/> removes all four, as A UI without a command chip shows.
  • <WithDragAndDropUpload/> no longer takes acceptedFiles. v14 ignored it too: the drop zone takes the types in messageComposer.attachments.acceptedFiles in client.config, as the file picker does. Delete the prop, and set the types there.
  • <MessageComposer/> no longer takes emojiSearchIndex or parent, the emojiSearchIndex override key is removed, and useMessageComposerContext() no longer returns emojiSearchIndex, parent or audioRecordingConfig. In v14 the two props only reached the composer context, and nothing read them there, so emoji autocomplete didn't come from them. Delete them. A thread's composer belongs to the thread already: to read the parent message, subscribe to parentMessage in the thread's state, useStateStore(useThreadContext()?.state, selector). audioRecordingConfig stays a <MessageComposer/> prop. v14 never set it in the context.
  • <MessageComposer/> takes generateRecordingTitle(mimeType), which names voice recording files. See Recording file names.
  • An audioRecordingConfig written inline no longer creates a new recorder on every render. See Encoding recordings as MP3 for when the recorder is replaced.
  • The stop button for an AI reply, <StopAIGenerationButton/>, now shows while an AI is thinking or generating, even when the input is empty or a cooldown is running. In v14, the record button and the cooldown timer took its place. If your app rendered a stop button of its own for those cases, remove it, or the user sees two.
  • additionalTextareaProps.className is now added to the textarea's classes. additionalTextareaProps.onPaste, or an onPaste on <TextareaComposer/>, now runs before the composer's paste handling, and calling event.preventDefault() in it skips that handling. v14 ignored both, so check that the values you pass still suit the app.

In v14, the emoji search index was passed like this:

<WithComponents overrides={{ emojiSearchIndex: SearchIndex }}>
  <MessageComposer emojiSearchIndex={SearchIndex} />
</WithComponents>

In v15, :shortcode suggestions come from the emoji middleware, which you add in the composer setup function. Emoji shows the setup.

Attachment selector

The File action, <SimpleAttachmentSelector/> and the plain upload button, <FileUploadButton/>, follow one rule. <FileUploadButton/> is what <AttachmentSelector/> renders when File is its only action. In v15 they show when both hold:

  1. messageComposer.attachments.enabled in client.config is true. That also needs the channel type's uploads setting, set in the Stream Dashboard or through the server API, unless messageComposer.attachments.customCdn is set.
  2. messageComposer.attachments.customCdn in client.config is true, or the user has the upload-file capability.

In v14, the File action needed messageComposer.attachments.enabled, upload-file, the channel type's uploads setting and a free file slot. <SimpleAttachmentSelector/> checked only upload-file.

  • With customCdn: true, users without upload-file see the File action and the upload button.
  • At the file limit, messageComposer.attachments.maxNumberOfFilesPerMessage in client.config, the File item and the upload button stay visible and are disabled. In v14, the File item was hidden. So was the plain button that stood in for the menu when File was the only action: the selector rendered nothing. A <SimpleAttachmentSelector/> that the composer rendered directly, which is the default while editing a message, stayed enabled.
  • AttachmentSelectorAction.StandaloneButton renders in place of the menu when its action is the only one offered. Only uploadFile has one by default, the newly exported FileUploadButton, so a custom upload type sets StandaloneButton: FileUploadButton. As in v14, an action with a type of its own is offered to every user, whatever their capabilities. See Standalone buttons.

Giphy

giphyVersion on <Attachment/> picks the rendition that loads, fixed_height by default. In v14, a Giphy attachment loaded its thumb_url, or its image_url when it had one, and giphyVersion set only its width and height. A custom imageAttachmentSizeHandler on <Attachment/> receives that rendition's URL in image_url. If your handler needs another URL, read it from attachment.giphy or thumb_url. See Attachments.

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.

ComponentContext.HeaderEndContent is removed too. <ChannelListHeader/> and <ThreadListHeader/> take StartContent and EndContent props instead, with no default:

  • Where the SDK renders the header, register a component that renders <ChannelListHeader/> or <ThreadListHeader/> with those props, under the ChannelListHeader or ThreadListHeader key. ThreadListHeader is a new key, and <ThreadListHeader/> is now exported.
  • One component no longer fills both headers. Pass it to each header that should show it.
  • In v14, the channel list header showed HeaderEndContent only while a channel was open, and the thread list header only while a thread was open. EndContent renders whenever it's set. To keep the v14 behaviour, return null from the content while openChannels or openThreads from useWorkspaceNavigation() is empty.

Channel list UI -> Header shows an example.

Channel details close through onClose

<ChannelDetail/> and <ChannelDetailProvider/> take an onClose prop. The sections' close buttons call it, and so do the actions that close the panel when they finish: opening a pinned message, leaving or deleting the channel, and sending a direct message.

Where <ChannelDetail/> renders v14 v15
In a modal The close button closes the modal. The same. With onClose, onClose is called in place of the modal's close; close the modal from it.
Outside a modal A close button that did nothing. No close button, unless you pass onClose.

An app that wrapped <ChannelDetail/> in <ModalContextProvider/> to make its close button work keeps working; onClose replaces the wrapper.

In v14:

import type { Channel as StreamChannel } from "stream-chat";
import { ModalContextProvider } from "stream-chat-react";
import { ChannelDetail } from "stream-chat-react/channel-detail";

type ChannelInfoPanelProps = { channel: StreamChannel; onClose: () => void };

export const ChannelInfoPanel = ({
  channel,
  onClose,
}: ChannelInfoPanelProps) => (
  <ModalContextProvider value={{ close: onClose }}>
    <ChannelDetail channel={channel} />
  </ModalContextProvider>
);

In v15:

import type { Channel as StreamChannel } from "stream-chat";
import { ChannelDetail } from "stream-chat-react/channel-detail";

type ChannelInfoPanelProps = { channel: StreamChannel; onClose: () => void };

export const ChannelInfoPanel = ({
  channel,
  onClose,
}: ChannelInfoPanelProps) => (
  <ChannelDetail channel={channel} onClose={onClose} />
);

A custom section reads close from useChannelDetailContext(), which returns { channel, close }, instead of from useModalContext(). close is undefined outside a modal without onClose, so <SectionNavigatorHeader/> shows no close button.

<ChannelDetail/> calls onLayoutChange with the layout on mount and on every change. v14 accepted the prop and never called it.

The channel members header actions use the shared action names

The headerActionSet of <ChannelMembersView/> uses the names the message actions and the channel list item actions use:

v14 v15
component Component
placement: "menu" placement: "dropdown"
filter isVisible, called with { channel } and typed with ChannelMembersHeaderActionVisibilityFilterParams
The HeaderActionsMenuTrigger prop, DefaultHeaderActionsMenuTrigger and ChannelMembersHeaderActionsMenuTriggerProps A quick-dropdown-toggle item in headerActionSet, a forwardRef button that spreads the props it gets onto its button
The closeMenu prop of a menu item closeMenu from useContextMenuContext()

TypeScript flags each of these. In plain JavaScript, two of them crash:

  • A quick item that still sets component crashes the members view.
  • A dropdown item that still calls the closeMenu prop throws TypeError: closeMenu is not a function. With closeMenu?.(), the menu stays open instead.

The rest fail quietly:

  • A leftover filter is ignored, so the action it hid shows to every user.
  • A menu item doesn't render.
  • A HeaderActionsMenuTrigger prop is ignored.
  • A set without a quick-dropdown-toggle item renders no menu. defaultChannelMembersHeaderActionSet holds one, so a set built from it keeps the menu.

ChannelMembersHeaderActionItem is a union, and its quick-dropdown-toggle member has no type or isVisible. Check item.placement before you read them. The channel members header action set describes the fields.

In v14:

import {
  ChannelMembersView,
  type ChannelMembersHeaderActionComponentProps,
  type ChannelMembersHeaderActionItem,
  type ChannelMembersHeaderActionsMenuTriggerProps,
  defaultChannelMembersHeaderActionSet,
  type SectionNavigatorSectionContentProps,
} from "stream-chat-react/channel-detail";
import { ContextMenuButton } from "stream-chat-react";

const MyTrigger = ({
  referenceRef,
  ...props
}: ChannelMembersHeaderActionsMenuTriggerProps) => (
  <button ref={referenceRef} {...props}>
    Actions
  </button>
);

const ExportMembers = ({
  closeMenu,
}: ChannelMembersHeaderActionComponentProps) => (
  <ContextMenuButton onClick={() => closeMenu?.()}>Export</ContextMenuButton>
);

const headerActionSet: ChannelMembersHeaderActionItem[] = [
  ...defaultChannelMembersHeaderActionSet,
  { component: ExportMembers, placement: "menu", type: "exportMembers" },
];

// The SectionContent of the "channel-members" section
const MembersSection = (props: SectionNavigatorSectionContentProps) => (
  <ChannelMembersView
    {...props}
    headerActionSet={headerActionSet}
    HeaderActionsMenuTrigger={MyTrigger}
  />
);

In v15:

import { type ComponentPropsWithoutRef, forwardRef } from "react";
import {
  ChannelMembersView,
  type ChannelMembersHeaderActionItem,
  defaultChannelMembersHeaderActionSet,
  type SectionNavigatorSectionContentProps,
} from "stream-chat-react/channel-detail";
import { ContextMenuButton, useContextMenuContext } from "stream-chat-react";

// Gets the ref, aria-expanded and onClick from the default header actions.
const MyTrigger = forwardRef<
  HTMLButtonElement,
  ComponentPropsWithoutRef<"button">
>(function MyTrigger(props, ref) {
  return (
    <button ref={ref} {...props}>
      Actions
    </button>
  );
});

const ExportMembers = () => {
  const { closeMenu } = useContextMenuContext();
  return (
    <ContextMenuButton onClick={() => closeMenu()}>Export</ContextMenuButton>
  );
};

const headerActionSet: ChannelMembersHeaderActionItem[] = [
  // The default set already holds the quick-dropdown-toggle item.
  ...defaultChannelMembersHeaderActionSet.map((item) =>
    item.placement === "quick-dropdown-toggle"
      ? { ...item, Component: MyTrigger }
      : item,
  ),
  { Component: ExportMembers, placement: "dropdown", type: "exportMembers" },
];

// The SectionContent of the "channel-members" section
export const MembersSection = (props: SectionNavigatorSectionContentProps) => (
  <ChannelMembersView {...props} headerActionSet={headerActionSet} />
);

A custom HeaderActions component runs useBaseChannelMembersHeaderActionSetFilter on the set first, renders the quick-dropdown-toggle item itself, and renders the dropdown items in a <ContextMenu/> anchored to that toggle, as <DefaultHeaderActions/> does. useContextMenuContext() throws outside a <ContextMenu/>.

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.

Removed exports and dependency

v14, from stream-chat-react v15
Root, Header Alert.Root, Alert.Header
UploadIcon IconPlus or IconAttachment
BinIcon IconDelete
PauseIcon IconPauseFill
PlayIcon IconPlayFill

The new icons follow the v15 design. To keep a v14 icon's shape, copy its SVG into your app.

@stream-io/transliterate is no longer installed with stream-chat-react. If your app imports it, add it to your own dependencies. To match mentions across alphabets, pass transliterate to a MentionsSearchSource and give it to createMentionsMiddleware(), as Customizing mention search shows.

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 the root of <ChatView/>, 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/>, the root of <ChannelNavigation/>, 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 the root of <ChannelNavigation/>. See Design tokens.
  • Quoted messages. Inside a message, a quoted message's text and the bar beside it now take the colours of the bubble it sits in, whoever wrote the quoted message: --str-chat__chat-text-* and --str-chat__chat-reply-indicator-*, -incoming in messages from others and -outgoing in your own. Its background, --str-chat__chat-bg-attachment-*, already followed the bubble in v14. In v14, the text always took --str-chat__chat-text-incoming, and the bar took --str-chat__chat-reply-indicator-outgoing whenever you wrote the quoted message. In the composer, the preview's text now takes the colour of the quoted message's author, --str-chat__chat-text-outgoing for your own messages, as its background already did. If you overrode those colours to fix the contrast, remove the override.
  • Stop button icon. The stop button for an AI reply draws the IconStopFill icon. v14 drew a background image from --str-chat__circle-stop-icon, which the SDK never defined, and v15 no longer reads it. If you set that variable, or drew an icon on .str-chat__stop-ai-generation-button in CSS, remove it. To change the icon, replace IconStopFill through the icons key, as Overriding components → Icons shows.
  • Thread list slide offset. --str-chat__thread-list-transition-offset takes a length, such as 8px, like --str-chat__channel-list-transition-offset. v14 multiplied it by -1px, so it worked only with a number without a unit, such as 8. If you set such a number, add the unit.
  • 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.