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

Upgrade to v10

This page lists what changed between v9 and v10 of Stream Chat React Native, and what to change in your app. Each change links to the v10 page that explains how the new version works. The samples import from stream-chat-react-native. In an Expo app, import the same names from stream-chat-expo.

Note:

This page starts from v9. Upgrade an app on v8 to v9 first with the "Upgrading from v8" guide, which you open by picking v9 in the version selector, and then follow this page.

v10 needs stream-chat 10, version 10.0.0-rc.26 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 Native 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 React Native to 0.79 or later and Expo to SDK 53 or later, then install the SDK and stream-chat from the rc tag. See Dependencies.
  2. Move the options you passed to useCreateChatClient or new StreamChat() under client. See Creating the client.
  3. Update how you read data. Your custom fields are under custom, 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. Render Thread next to Channel instead of inside it, pass it a Thread object, and render the thread's list and composer as its children. See Threads.
  6. Move the Channel props that customized requests, throttling, uploads and pruning to client.config. See Channel props that moved.
  7. Replace TypingContext, PaginatedMessageListContext and the removed context fields with reads from the channel's stores. See Reading channel state.
  8. Call message operations through useMessageOperations() instead of useMessagesContext(). See Messages.
  9. Replace isOnline and connectionRecovering from useChatContext(), and any connection.changed listener. See Connection status.
  10. Rename your translation keys, and supply any language other than English yourself. See Localization.
  11. Expect the offline database to be rebuilt on the first launch after the upgrade. See Offline storage.

What's new

Dependencies

Package v9 v10
stream-chat ^9 ^10.0.0-rc.26
react-native (peer) >=0.76 >=0.79
expo (peer, Expo only) >=52 >=53
dayjs 1.11.13, pinned ^1.11.23
i18next a direct dependency none. It comes through @stream-io/i18n
@stream-io/i18n none a new dependency, for the translation runtime

Upgrade React Native or Expo first. stream-chat and @stream-io/i18n resolve their files through package exports, which Metro reads by default from the version that React Native 0.79 uses.

stream-chat 10 requires Node 22.18 or later in its engines field. Node is not your app's runtime, but it runs your install and your CI. Yarn 1 refuses to install on an older Node, and npm and pnpm print a warning.

Your app imports from stream-chat too, so add it at the version the SDK uses. Exactly one copy must resolve. The same goes for dayjs. If your app declares it, use a range that matches ^1.11.23, or a second copy installs and the dayjs locales you import never reach the dates the SDK formats. Don't declare i18next.

StateStore and Unsubscribe are no longer exported from stream-chat. If you build your own stores, import them from @stream-io/state-store, add that package to your app, and keep one copy of it installed. A second copy makes your stores unassignable to useStateStore.

- import { StateStore, type Unsubscribe } from "stream-chat";
+ import { StateStore, type Unsubscribe } from "@stream-io/state-store";

Localization

If you use the SDK in English and never customized translations, there is nothing to do here. The v10 behavior is on Localization. This section is the migration.

English is the only bundled language

The ar, es, fr, he, hi, it, ja, ko, nl, pt-BR, ru and tr dictionaries are removed, together with their dayjs calendar formats. So are the exports enTranslations, esTranslations, frTranslations, heTranslations, hiTranslations, itTranslations, jaTranslations, koTranslations, nlTranslations, ptBRTranslations, ruTranslations and trTranslations. Metro doesn't drop unused code, so every app bundled all twelve languages, even one that only rendered English.

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

git show v9.10.1:package/src/i18n/nl.json > nl.json

Its keys are the old ones, so rename them before you register it, as the next section describes. You also need the dayjs locale and its calendar formats, which Localization shows.

Translation keys are dotted identifiers

A key used to be the English copy itself. It's now a stable dotted identifier, and the SDK passes the English copy at each call as i18next's defaultValue. The v9 key a11y/Send message became messageInput.sendMessage.accessibilityLabel. The a11y/ prefix is gone, and what the text is for is the last segment of the key.

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.

The table that maps every v9 key to its v10 key, 389 rows, is ai-docs/i18n-v10-key-map.json in the SDK repository. A few rows:

v9 key v10 key
Cancel common.cancel.label
Reply message.reply.label
Send a message autoCompleteInput.placeholder
a11y/Send message messageInput.sendMessage.accessibilityLabel
{{count}} new messages messageList.newMessages.text, a plural key

A German dictionary in v9:

i18n.registerTranslation("de", {
  Cancel: "Abbrechen",
  "a11y/Send message": "Nachricht senden",
  "{{count}} new messages": "{{count}} neue Nachrichten",
});

The same dictionary in v10:

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

const de: TranslationDictionary = {
  "common.cancel.label": "Abbrechen",
  "messageInput.sendMessage.accessibilityLabel": "Nachricht senden",
  "messageList.newMessages.text_one": "{{count}} neue Nachricht",
  "messageList.newMessages.text_other": "{{count}} neue Nachrichten",
};

export const i18n = new Streami18n();
i18n.registerTranslation("de", de);

Typing the dictionary as TranslationDictionary turns a missed key into a compile error. Streami18n.registerTranslation() and translationsForLanguage take that type, so a dictionary written inline is checked without the annotation.

Five count strings that took {{count}} but weren't plural in v9 are plural keys now: messageList.newMessages.text, messageList.scrollToBottom.withCount.accessibilityLabel, channelPreview.unreadCount.accessibilityLabel, message.reactionList.more.accessibilityLabel and avatar.channel.group.accessibilityLabel. Provide their _one and _other forms, and the other plural forms your language needs. A translation of the bare key no longer applies.

One v9 key became two. a11y/Double tap to open was the screen reader hint of both an image gallery item and a channel list row. In v10 they're attachment.gallery.doubleTapToOpen.accessibilityLabel and channelPreview.doubleTapToOpen.accessibilityLabel, so a translation of one no longer changes the other. Translate both. The key map lists the second one under alsoKeys.

Your own keys

t() from useTranslationContext() is typed StreamTFunction, and accepts only the SDK's keys. To translate your app's own copy on the same Streami18n instance, register it in a LooseTranslationDictionary, and wrap the key in asDynamicKey() when you read it:

import { Text } from "react-native";
import {
  asDynamicKey,
  Streami18n,
  useTranslationContext,
} from "stream-chat-react-native";
import type { LooseTranslationDictionary } from "stream-chat-react-native";

const appKeys: LooseTranslationDictionary = {
  "myApp.settings.title": "Einstellungen",
};

export const i18n = new Streami18n({ language: "de" });
i18n.registerTranslation("de", appKeys);

export const SettingsTitle = () => {
  const { t } = useTranslationContext();
  return <Text>{t(asDynamicKey("myApp.settings.title"), "Settings")}</Text>;
};

Nothing checks the SDK's keys inside a LooseTranslationDictionary, so keep those in a TranslationDictionary.

Keys whose value is not copy

The timestamp/* and duration/* keys became timestamp.* and duration.*. Their values are dayjs and i18next expressions. The SDK ships them as defaults that every dictionary you register is merged over, so you list one only to change it. Two of them, timestamp.ChannelPreviewStatus and timestamp.ThreadListItem, put English day words such as "Yesterday" in their own calendarFormats argument. To translate those words, override the two keys. Localization has the expressions.

Four formatter keys that v9 shipped but never rendered are gone from the catalog, with no replacement key: duration/Location end at, duration/Remind Me, timestamp/ReminderNotification and timestamp/MessageEditedTimestamp. If your app called them, register the expressions under keys of your own, for every language you register, English included:

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

const appFormatterExpressions: LooseTranslationDictionary = {
  "duration.locationEndAt":
    "{{ milliseconds | durationFormatter(withSuffix: false) }}",
  "duration.remindMe":
    "{{ milliseconds | durationFormatter(withSuffix: true) }}",
  "timestamp.reminderNotification":
    "{{ timestamp | timestampFormatter(calendar: true) }}",
};

export const i18n = new Streami18n();
i18n.registerTranslation("en", appFormatterExpressions);

The relativeCompactDateFormatter formatter is removed. In an expression you override, write {{ timestamp | timestampFormatter(relativeCompact: true) }} instead. An unknown formatter doesn't throw, so a leftover one renders the timestamp wrong.

Convert a server timestamp with convertTimestampToDate() from stream-chat before you pass it to t("timestamp.X", { timestamp }). The interpolation values aren't type-checked, and a raw nanosecond timestamp renders as "Invalid Date". See Timestamps are nanosecond numbers.

Notifications and poll errors

The SDK translates a notification by its notification.type, not by its English message. A dictionary keyed by the English notification text no longer applies.

The errors in the poll composer's state are PollComposerValidationError objects instead of English strings. If you render them yourself, choose your copy by error.code and fall back to error.message.

The translation runtime

Streami18n is built on the @stream-io/i18n package, shared with the React SDK. You still import it from stream-chat-react-native. Code that calls the instance directly needs these changes:

v9 v10
new Streami18n(options, i18nextConfig) new Streami18n({ ...options, i18nextConfigOverrides: i18nextConfig })
await i18n.getTranslators() await i18n.init(), which resolves to the same value
const t = await i18n.setLanguage("de") await i18n.setLanguage("de") resolves to nothing. Read t from i18n.state
i18n.addOnLanguageChangeListener(listener) i18n.state.subscribeWithSelector(({ t }) => ({ t }), ({ t }) => listener(t))
i18n.addOnTFunctionOverrideListener(listener) subscribe to i18n.state
i18n.geti18Instance() i18n.i18nInstance
i18n.getAvailableLanguages() i18n.registeredLanguages, a read-only Set
i18n.getTranslations() removed. Call i18n.t(key) to read a translation
i18n.isCustomDateTimeParser, i18n.Dayjs removed

Streami18n.registerTranslation() merges the dictionary into the language's existing one, so repeated calls add up, and a partial dictionary keeps the bundled formatter keys. Its third argument is a DayjsLocaleConfig.

The date helpers were deep imports in v9 and are exported from the package root in v10. getDateString({ date }) takes messageCreatedAt instead of date, and returns null instead of undefined when there is nothing to render. getDateStringForA11y is now getCalendarDateStringForA11y. The name getDateStringForA11y still exists, but it formats the date in full, as the React SDK does.

Every component and hook that takes or returns t types it as StreamTFunction. useTranslationContext().userLanguage is a string, and DEFAULT_USER_LANGUAGE is "en". stream-chat's translation API takes a closed set of languages, so narrow userLanguage before you pass it there. The values of SCROLL_TO_BOTTOM_ACCESSIBILITY_LABEL_KEY and SCROLL_TO_BOTTOM_WITH_COUNT_ACCESSIBILITY_LABEL_KEY are the dotted keys. isDayOrMoment comes from @stream-io/i18n, and narrows to DateTimeLike. TDateTimeParserInput and TDateTimeParserOutput are typed by @stream-io/i18n too.

Creating the client

stream-chat 10 builds the client from one configuration tree, the same tree client.config.set() takes. The options v9 took at the top level now go under client. useCreateChatClient passes its options to the client, so they move too.

In v9:

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

In v10:

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

const apiKey = "YOUR_API_KEY";
const userId = "YOUR_USER_ID";
const userToken = "YOUR_USER_TOKEN";

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

  if (!client) return <Text>Connecting...</Text>;

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

A client you create yourself takes the same tree, new StreamChat(apiKey, { client: { baseURL } }), and so does StreamChat.getInstance(). The secret argument is gone.

Where the common v9 options went:

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

userData is typed ClientUser, the type client.connectUser() takes. Your own user fields go under custom. A UserResponse no longer fits there, because it requires fields the server sets, such as created_at.

Chat writes persistUserOnConnectionFailure from its enableOfflineSupport prop when it mounts, and overwrites a value you set. To keep it true without offline support, set it again after Chat mounts. Chat no longer turns off the client's own reconnect recovery, as v9 did, and no longer sets client.preventThreadCleanup. See Connection status.

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, an attachment 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
message.ai_generated message.custom?.ai_generated
channel.data?.name channel.data?.custom?.name
channel.data?.image channel.data?.custom?.image
attachment.mime_type, file_size attachment.custom?.mime_type, attachment.custom?.file_size
Channel.sendMessage({ text, priority }) Channel.sendMessage({ message: { text, custom: { priority } } })
client.channel(type, id, { name }) client.channel(type, id, { custom: { name } })

customDataManager.setMessageData() sends its fields under custom too.

In filters, custom fields are "custom.<field>" keys, and each key takes one operator. To type your fields, extend the Custom*Data interfaces of stream-chat, as Data conventions shows.

message.moderation_details is replaced by message.moderation. isBlockedMessage and isBouncedMessage read only message.moderation, so if your backend still sends the older moderation payload, they return false.

Timestamps are nanosecond numbers

Every date the server sends, such as created_at, is a TimestampNS, a number that holds Unix time in nanoseconds. In v9 messages carried Date objects and other data carried ISO strings.

  • Compare and sort timestamps as numbers.
  • Convert where a timestamp meets a Date, a date library or Date.now(), with convertTimestampToDate(), nsToDate() or nsToMs() from stream-chat. To create one, use nowNs(), msToNs(), dateToNs() or asTimestampNS().
  • new Date(message.created_at) is marked deprecated, and its result has none of the methods of a Date, so code that uses it as a date, such as new Date(message.created_at).getTime(), doesn't compile. dayjs(message.created_at) compiles, because TimestampNS is a number, but reads nanoseconds as milliseconds. The SDK's getDateString turns the result into null, so a missed conversion shows as a blank timestamp.

Dates you send in a request, such as remind_at, are still Date objects. The components that render a date, such as MessageFooter, still take a Date or a string.

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 }
export const sort: SortParamRequest[] = [
  { direction: -1, field: "pinned_at" },
  { direction: -1, field: "last_message_at" },
];

The first field sorts first. ChannelListEventListenerOptions.sort and the sort of UseFetchReactionParams take the same type.

Other client changes

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

  • client.queryChannels() is a plain request that returns the response and stores no channels. The v9 behavior is client.queryChannelsAndHydrate({ filter_conditions, sort, ...options }), which returns Channel[].
  • Methods take request objects, with path parameters first, such as Channel.sendMessage({ message }), client.search({ payload }), client.queryUsers({ payload }), Channel.queryMembers({ payload }), and Channel.sendEvent({ event: { type, custom } }).
  • Channel.update({ name }) is Channel.updatePartial({ set: { name } }), or Channel.update({ data: { custom: { name } } }). Members are objects, as in members: [{ user_id }].
  • client.addDevice(), client.getDevices() and client.removeDevice() are client.createDevice({ id, push_provider, push_provider_name }), client.listDevices() and client.deleteDevice({ id }). client.setLocalDevice() and the device option are removed.
  • Moderation calls moved to client.moderation and take request objects. client.muteUser(id) is client.moderation.mute({ target_ids: [id] }), client.banUser(id) is client.moderation.ban({ target_user_id: id }), client.flagMessage(id) is client.moderation.flagMessage({ message_id: id }), and client.flagUser(id) is client.moderation.flagUser({ user_id: id }).
  • Channel.addMembers(members) is Channel.addMembers({ members }). client.pinMessage(message, expiration) is client.pinMessage(message, { pin_expires, pinned_at }): the first argument is anything with an id, and the expiration moves into an options object. client.unpinMessage(message) is unchanged.
  • client.userID and client.clientID are deprecated. Use client.userId and client.clientId.
  • The paginators in stream-chat 10, such as channel.messagePaginator and ChannelPaginator, have no next(), prev(), nextDebounced(), prevDebounced(), hasNext or hasPrev. Use paginator.toTail(), paginator.toHead(), paginator.toTailDebounced(), paginator.toHeadDebounced(), hasMoreTail and hasMoreHead. A paginator's state has no hasNext and hasPrev either, so a selector reading them gets undefined. Select hasMoreTail and hasMoreHead. A cursor's next and prev are tailward and headward. Head and tail explains both ends.
  • client.reminders.upsertReminder({ message_id }, { remind_at }) takes a Date. Channel.stopLiveLocationSharing({ message_id }) takes the message id. Call LiveLocationManager.dispose() on a live location manager you created when you're done with it.
  • client.uploadFile() and client.uploadImage() take { file: { uri, name, type } }, and replace Channel.sendFile() and Channel.sendImage() too.
  • Channel.lastMessage() is removed. See The channel's latest message.
  • client.activeChannels, client.getChannelById() and client.getChannelByMembers() are removed. Use client.channelManager.get(cid), client.channelManager.values() and client.channel(). Channels stay in the client until client.channelManager.releaseUnusedChannels() releases the ones nothing uses. client.hydrateActiveChannels() is client.hydrateChannels().
  • Channel.getConfig() is the channel.serverConfig getter, and client.configs[cid] is client.channelServerConfigs[cid]. What the client enforces is channel.config, the server's settings combined with yours.
  • client.setMessageComposerSetupFunction(fn) is deprecated. Use client.config.setSetupFunction("messageComposer", fn).
  • Event is a union of event types. Fields such as event.me exist only on the event types that carry them, so narrow with EventPayload<"...">, or let client.on("health.check", ...) narrow it.
  • Many types were renamed, among them Mute to UserMuteResponse, ReminderResponse to ReminderResponseData, SharedLocationResponse to SharedLocationResponseData, MessageResponseBase to MessageResponse, MessageRequest to SendMessageRequest, and DeliveredMessageConfirmation to DeliveredMessagePayload. DraftFilters, DraftSort and Pager are replaced by QueryDraftsRequest. Search results are SearchResultMessage, with an optional message. Some names stayed with a different meaning, such as User, Reaction, PollResponse and ChannelConfig, so check what an annotation resolves to.
  • API errors are StreamAPIError, which replaces ErrorFromResponse.
  • Logging goes through chatLoggerSystem from stream-chat, and is on by default, to the console at info level. Turn it down with chatLoggerSystem.configureLoggers({ default: { level: "warn" } }).

Connection status

In v9, "online" meant that the WebSocket was up. stream-chat 10 reports two things separately, whether the device has a network, in client.networkConnection, and whether the socket is up, in client.wsConnection. The v10 behavior is on Connection.

useChatContext() no longer returns isOnline or connectionRecovering. Read the status with these hooks:

Hook What it reports
useNetworkConnectionState() The device's network. Its isOnline is undefined until the network is first reported, so test it with === false
useWSConnectionState() The socket's raw state, such as isHealthy and lastHealthyAt
useSettledWSConnectionHealth() Whether the socket counts as healthy for UI. A drop counts once it lasts offlineNotificationDisplayDelayMs, 5 seconds by default

In v9:

const { isOnline } = useChatContext();
if (!isOnline) return <OfflineBanner />;

In v10:

import { Text } from "react-native";
import {
  useNetworkConnectionState,
  useSettledWSConnectionHealth,
} from "stream-chat-react-native";

export const ConnectionBanner = () => {
  const isDeviceOffline = useNetworkConnectionState()?.isOnline === false;
  const isSocketHealthy = useSettledWSConnectionHealth();

  if (isDeviceOffline) return <Text>Waiting for network...</Text>;
  if (!isSocketHealthy) return <Text>Reconnecting...</Text>;
  return null;
};

Change the delay with client.config.set({ client: { wsConnection: { offlineNotificationDisplayDelayMs: 3000 } } }).

Other changes around the connection:

  • useIsOnline(client) is renamed useConnectionLifecycle(client), and returns nothing. It installs the network reporter and closes the socket while the app is in the background. Chat already calls it.
  • Chat registers netInfoStatusReporter, built on @react-native-community/netinfo, when it mounts, unless you registered a statusReporter on the client yourself. A client you build yourself can take the reporter at construction, with new StreamChat(apiKey, { client: { networkConnection: { statusReporter: netInfoStatusReporter } } }).
  • The connection.changed event is no longer sent. A listener for it compiles and never runs. Subscribe to client.wsConnection.state or client.networkConnection.state instead.
  • NetworkDownIndicator shows "Waiting for network..." when the device is offline, and "Reconnecting..." when the socket has been down longer than the delay. It no longer shows an error when messages fail to load. ChannelListHeaderNetworkDownIndicator and NotificationAnnouncer follow the same two signals.
  • MessageComposer's and OutputButtons' isOnline prop is a boolean, the settled socket health, instead of boolean | null.

Recovery after a reconnect

The client recovers after a reconnect by itself. It reloads each registered channel list, every active channel and every active thread, and then sends connection.recovered. A channel list reloads every page it has loaded, up to 150 channels, and drops the channels no page returned, such as ones deleted or hidden while the app was offline. This runs on every reconnect, including the one after the app returns from the background, which v9 missed. Channel, Thread and ChannelList no longer resync on their own. Delete any listener of yours that re-queries a list or re-watches a channel after a reconnect, because it now doubles the work.

  • recoverStateOnReconnect is removed. To turn recovery off, set client.config.set({ client: { connectionRecovery: { enabled: false } } }). v9's Chat set client.recoverStateOnReconnect = false for you, and v10's doesn't.
  • client.recoverState() is client.connectionRecovery.recover().
  • A listener for connection.recovered now also runs after the app returns from the background. If it's expensive, check it.
  • Recovery reloads only active channels and threads. Channel and Thread activate theirs while they're mounted. A screen of your own that shows a channel or a thread without them must call Channel.activate() or Thread.activate(), and call the function it returns when the screen goes away. There is no Channel.deactivate() or Thread.deactivate().

A request that watches a channel or asks for presence, such as Channel.watch(), queryChannels and queryUsers with presence, waits for the socket's connection id. v9 sent it without one, and the server registered nothing. With no socket open or opening, it throws, so connect the user first. See Troubleshooting.

Channels

Channel props that moved

Props that customized requests and the message list moved to client.config, or to the component that uses them:

v9 prop on Channel v10
doSendMessageRequest, preSendMessageRequest client.config.set({ channel: { requestHandlers: { sendMessageRequest, retrySendMessageRequest } } })
doUpdateMessageRequest client.config.set({ channel: { requestHandlers: { updateMessageRequest } } })
doMarkReadRequest client.config.set({ channel: { requestHandlers: { markReadRequest } } })
doFileUploadRequest client.config.set({ messageComposer: { attachments: { doUploadRequest, customCdn: true } } })
stateUpdateThrottleInterval, newMessageStateUpdateThrottleInterval client.config.set({ channel: { messagePaginator: { stateThrottleMs } } })
maximumMessageLimit client.config.set({ channel: { messagePaginator: { maxLoadedItems } } })
allowSendBeforeAttachmentsUpload client.config.set({ messageComposer: { attachments: { pendingUploadsEnabled } } }). See Composer
enableOfflineSupport removed. Set it on Chat
isMessageAIGenerated removed. Set it on Chat, which always overrode it
messageId removed. See Opening a channel at a message
thread, threadList, allowThreadMessagesInChannel, shouldSyncChannel removed. Render Thread next to Channel. See Threads
messages, loadingMore, loadingMoreRecent, threadMessages, setThreadMessages removed. The lists read the message paginators
isOnline removed. It was never read

The request handlers change shape as well as place. They take one params object instead of positional arguments, plus the default request as a second argument, and resolve to { message }. They're registered once per client, not per mounted Channel, so to treat channels differently, branch inside the handler on params.localMessage.cid. Register them before the client reaches Chat, not from a component's effect.

In v9:

<Channel
  channel={channel}
  doSendMessageRequest={(channelId, message, options) =>
    mySend(channelId, message, options)
  }
  doUpdateMessageRequest={(channelId, message, options) =>
    myUpdate(channelId, message, options)
  }
/>

In v10:

import { StreamChat } from "stream-chat";
import type {
  DefaultOperationRequest,
  OperationParams,
  OperationRequestFn,
} from "stream-chat";

const client = StreamChat.getInstance("YOUR_API_KEY");

// Generic over "send" and "retry", so the same function fits both handlers.
const sendMessageRequest = async <K extends "send" | "retry">(
  params: OperationParams<K>,
  defaultRequest: DefaultOperationRequest<K>,
) => {
  // Inspect or change the message here, then send it the default way.
  console.log("Sending to", params.localMessage.cid);
  return defaultRequest(params);
};

const updateMessageRequest: OperationRequestFn<"update"> = async (
  params,
  defaultRequest,
) => defaultRequest(params);

client.config.set({
  channel: {
    requestHandlers: {
      retrySendMessageRequest: sendMessageRequest,
      sendMessageRequest,
      updateMessageRequest,
    },
  },
});
  • sendMessageRequest runs for the first send, and for a retry when no retrySendMessageRequest is registered. A registered retrySendMessageRequest takes over retries.
  • sendMessageRequest replaces preSendMessageRequest too, but runs later. It runs after the message appears in the list and after its attachments finish uploading. If it throws, the message is marked failed, where preSendMessageRequest left it pending.
  • A handler that resolves without a message sends nothing more, as in v9. The message.new event updates the message.
  • Thread replies go through the same channel handlers. The thread key takes only markReadRequest.
  • customCdn: true says your function sends files to storage Stream doesn't host, so the channel type's uploads flag and the upload-file capability stop gating the composer's uploads. Leave it out if your function uploads to Stream. The attach button still shows only to users with upload-file.

What Channel does on mount

Channel still watches its channel when it mounts, loads the first page of messages, and marks the channel read. Several details changed:

  • The first page has 25 messages, as in v9, where it was the server's default. stream-chat 10's own default is 100, so Channel sets 25 unless you register a page size. To change it, register a page size with client.config.set({ channel: { messagePaginator: { pageSize: 50 } } }). Setting channel.messagePaginator.pageSize after mount doesn't last.
  • Channel no longer listens to the channel's events, resyncs after a reconnect, or renders nothing when the channel is deleted. See A deleted channel keeps rendering.
  • A failed Channel.watch() no longer shows the full-screen error. Channel renders LoadingErrorIndicator when the channel's message paginator has an error and no messages to show.
  • initializeOnMount={false} skips Channel.watch(), but still sends a query that doesn't watch when the paginator has no messages. To send no request at all, load the paginator before you mount Channel.
  • A channel created from members only, without an id, that your code has already marked initialized, for example to show a conversation before it exists, isn't queried on mount. Its first message creates it. A members-only channel that isn't marked initialized is watched on mount, which creates it.
  • When channel.supersededBy is set, Channel renders that channel instead of the one you passed. Inside Channel, read the channel from useChannelContext() or useChannel(), not from your own prop.
  • client.options.isLocalUnreadCountEnabled is no longer read. Use client.config.set({ channel: { readEvents: { localUnreadCountEnabled: true } } }).
  • Sending a message no longer removes type: "error" messages from the list. Remove one with useMessageOperations().removeMessage(message).

watch() resolves with the channel

Channel.watch() loads and watches a channel only when it needs to. On a channel that's already watched and loaded, such as one from a channel list, it sends no request, unless you pass options the current watch wasn't made with, such as presence: true. A call made while the same watch is in flight joins it instead of sending another. It resolves with the Channel to use from then on: this one, or the one that superseded it. v9's Channel.watch() sent a query on every call and resolved with the server response.

  • Code that read the response, such as const { messages } = await channel.watch(), reads the channel instead: its messages from channel.messagePaginator, and its members and read state from channel.state. See Reading channel state.
  • Calling Channel.watch() again no longer refreshes the channel. Call Channel.reload(). To load other messages, use MessagePaginator.toTail(), MessagePaginator.toHead() or MessagePaginator.jumpToMessage().
  • Keep using the channel Channel.watch() resolves with. A channel created from its members gets its real cid from its first watch, and the object you hold may have been superseded. See Superseded channels.
  • Earlier v10 release candidates had Channel.ensureWatched(). It's removed, and Channel.watch() does what it did.

Opening a channel at a message

The messageId prop is removed. Jump to the message through the channel's message paginator, from a layout effect, so the jump starts before the list picks its first scroll position. Turn off initialScrollToFirstUnreadMessage while you do, or the two jumps race:

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

type Props = { channel: StreamChannel; messageId?: string };

export const ChannelScreen = ({ channel, messageId }: Props) => {
  useLayoutEffect(() => {
    if (!messageId) return;
    channel.messagePaginator.jumpToMessage(messageId, {
      focusReason: "jump-to-message",
      focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
    });
  }, [channel, messageId]);

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

A deleted channel keeps rendering

v9's Channel rendered nothing once its channel was deleted. v10's keeps rendering it, as the React SDK does, and what to show is up to your app. A channel object is gone for good when the channel is deleted, when the user is removed from it, or when the client disconnects. Its pendingDisposal state says so:

import type { PropsWithChildren } from "react";
import type {
  ChannelLifecycleState,
  Channel as StreamChannel,
} from "stream-chat";
import { Channel, useStateStore } from "stream-chat-react-native";

const pendingDisposalSelector = (state: ChannelLifecycleState) => ({
  pendingDisposal: state.pendingDisposal,
});

export const ChannelIfPresent = ({
  channel,
  children,
}: PropsWithChildren<{ channel: StreamChannel }>) => {
  const { pendingDisposal } = useStateStore(
    channel.state,
    pendingDisposalSelector,
  );

  // Or render your own "this channel is gone" view.
  if (pendingDisposal) return null;

  return <Channel channel={channel}>{children}</Channel>;
};

channel.disconnected is renamed channel.pendingDisposal, with no alias. A disposed channel object never comes back. To open the same conversation again, get a new object with client.channel(type, id).

Reading channel state

In stream-chat 9, channel.state was a plain object, and the SDK copied parts of it into contexts. In stream-chat 10, channel.state is a store, and the SDK no longer copies it. Read it with useStateStore and a selector declared outside the component. Changing a field of channel.state or channel.data in place notifies no one. To change channel.data, assign a new object. The v10 behavior is on Reading state in components.

TypingContext and PaginatedMessageListContext are removed, with their providers, their use...Context hooks, their value types, and useCreateTypingContext and useCreatePaginatedMessageListContext. So is DebugContext, which fed the Flipper plugin, with nothing in its place.

In v9:

const { typing } = useTypingContext();
const { messages, hasMore, loadMore } = usePaginatedMessageListContext();
const { members, watcherCount } = useChannelContext();

In v10:

import { Button, Text, View } from "react-native";
import type { ChannelStateData, MessagePaginatorState } from "stream-chat";
import {
  useChannel,
  useMessagePaginator,
  useStateStore,
} from "stream-chat-react-native";

const channelSelector = (state: ChannelStateData) => ({
  memberCount: state.memberCount,
  typing: state.typing,
  watcherCount: state.watcherCount,
});

const messagesSelector = (state: MessagePaginatorState) => ({
  hasMore: state.hasMoreTail,
  messages: state.items,
});

export const ChannelSummary = () => {
  const channel = useChannel();
  const paginator = useMessagePaginator();
  const { memberCount, typing, watcherCount } = useStateStore(
    channel.state,
    channelSelector,
  );
  const { hasMore, messages } = useStateStore(
    paginator.state,
    messagesSelector,
  );

  return (
    <View>
      <Text>
        {messages?.length ?? 0} messages, {memberCount} members, {watcherCount}{" "}
        online, {Object.keys(typing).length} typing
      </Text>
      {hasMore ? (
        <Button onPress={() => paginator.toTail()} title="Load older" />
      ) : null}
    </View>
  );
};

useChannel() returns the channel of the surrounding Channel, or the thread's channel inside a Thread. useMessagePaginator() returns the thread's message paginator inside a Thread, and the channel's elsewhere. In a message list, the tail is where older messages load, and the head is the newest message.

Where the removed context fields went:

v9 v10
typing from useTypingContext() typing in channel.state
messages from usePaginatedMessageListContext() items in useMessagePaginator().state
hasMore, hasMoreNewer, loadingMore, loadingMoreRecent hasMoreTail (older), hasMoreHead (newer) and isLoading in the same state
setLoadingMore, setLoadingMoreRecent removed. The paginator sets isLoading itself
viewabilityChangedCallback viewabilityChangedCallback from useMessageList({ paginator }). It calls MessagePaginator.setPruningSuspended(), which holds off pruning while the user is near the messages it would remove
loadMore(), loadMoreRecent(), loadLatestMessages() paginator.toTail(), paginator.toHead() and paginator.jumpToTheLatestMessage()
members, read, watchers, watcherCount from useChannelContext() the same keys in channel.state
members from useMessageContext() members in channel.state
markRead from useChannelContext() useMarkRead(channel), or Channel.markReadViaReporter(). See Read state
loadChannelAroundMessage({ messageId }) paginator.jumpToMessage(messageId, { focusReason: "jump-to-message" })
loadChannelAtFirstUnreadMessage() paginator.jumpToTheFirstUnreadMessage()
reloadChannel() paginator.jumpToTheLatestMessage()
loading isLoading in paginator.state
error lastQueryError in paginator.state
targetedMessage, setTargetedMessage, highlightedMessageId, and useTargetedMessage paginator.jumpToMessage(id, ...) to set, useIsTargetedMessage(id) to read
channelUnreadStateStore, setChannelUnreadState paginator.unreadStateSnapshot
isChannelActive !useThreadContext().thread
maximumMessageLimit maxLoadedItems in the paginator's configuration
uploadAbortControllerRef removed. Remove an attachment from the composer to cancel its upload
scrollToFirstUnreadThreshold removed. It was always 0
useSelectedChannelState({ channel, selector }) useStateStore(channel.state, selector)

useChannelContext().error was the error of the last failed query. Read lastQueryError from the paginator of the list you render, which useMessagePaginator() returns. NetworkDownIndicator no longer shows it, so render your own banner from it if you want one.

Most keys of channel.state keep their v9 names. watcher_count is watcherCount, and member_count is memberCount. The state also holds values that lived elsewhere, such as data, membership, muteStatus, ownCapabilities, initialized, pendingDisposal, watchStatus and aiState. The getters, such as channel.state.members, still work, but they return the value at the moment you read them, and don't tell you when it changes, so they re-render nothing. What channels and threads hold lists the fields.

The messages moved off channel.state too:

v9 v10
channel.state.messages items in channel.messagePaginator.state
channel.state.findMessage(id) channel.messagePaginator.getItem(id)
channel.state.loadMessageIntoState(id) channel.messagePaginator.jumpToMessage(id)
channel.state.isUpToDate channel.messagePaginator.isActiveIntervalAtHead
channel.state.pinnedMessages channel.pinnedMessagesPaginator
channel.state.threads[id] client.threads.get(id)?.messagePaginator
channel.state.mutedUsers useMutedUsers()
Channel.query({ messages: { id_lt } }) channel.messagePaginator.toTail()

Channel.query() returns a page and no longer puts it in channel.messagePaginator. Its second argument, the message set to add the messages to, is removed, and the second argument is now the request options, such as an abort signal. Open a channel with Channel.watch(), and load more messages with MessagePaginator.toTail(), MessagePaginator.toHead() or MessagePaginator.jumpToMessage().

The channel's latest message

Channel.lastMessage() and channel.state.latestMessages are removed. The channel's newest message is in its message paginator's aggregateState, whichever interval of messages is on screen. channel.messagePaginator.latestMessage reads it once, and is null when there is none.

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

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

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

ChatContext holds the client and two functions

useChatContext() returns client, getAppSettings and isMessageAIGenerated. The other fields are removed:

v9 useChatContext() v10
appSettings await getAppSettings(). The first call fetches the settings, and later calls reuse them
mutedUsers useMutedUsers()
channel, setActiveChannel keep the open channel in your navigation state, and pass it to Channel
enableOfflineSupport client.offlineDb !== undefined
isOnline, connectionRecovering the connection hooks. See Connection status

useAppSettings and useClientMutedUsers are removed. Use getAppSettings() and useMutedUsers(). useMutedUsers() takes no argument, and returns UserMuteResponse[]. useMutedChannels() takes no argument either. For one channel's mute state, use useIsChannelMuted(channel), which returns a ChannelMuteStatus with nanosecond timestamps.

Channel lists

ChannelList no longer creates its query. Its data comes from a ChannelPaginator that your app creates and registers on client.channelManager, and the list renders what that paginator holds. The v10 behavior is on Channel list paginators.

In v9:

<ChannelList
  filters={{ members: { $in: [userId] }, type: "messaging" }}
  onSelect={onSelect}
  options={{ presence: true, state: true, watch: true }}
  sort={{ last_message_at: -1 }}
/>

In v10:

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

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

type Props = { onSelect: (channel: Channel) => void; userId: string };

export const MyChannelList = ({ onSelect, userId }: Props) => {
  const { client } = useChatContext();

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

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

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

What to know about the paginator:

  • Register it. 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.
  • The list never removes or disposes the paginator. Your app does, when nothing shows it any more, for example when the user logs out. client.disconnectUser() empties the registered paginators but keeps them registered.
  • The list loads a first page only when the paginator has none, which happens on the first mount, after client.disconnectUser() empties it, and when you pass a different paginator. A list that mounts again with the same paginator shows its channels without a query. To keep the channels while the list's screen is unmounted, create the paginator above the navigator, with the user.
  • Give each paginator a unique id. The manager keys its lists by it.
  • The query options split in two. Options of the request, such as presence, state, watch and message_limit, go in requestOptions. The page size goes in paginatorOptions.pageSize. offset and limit aren't accepted, because the paginator owns them.

Where the removed props went:

v9 prop on ChannelList v10
filters, sort, options the filters, sort and requestOptions of the ChannelPaginator. options.limit is paginatorOptions.pageSize
lockChannelOrder paginatorOptions.lockItemOrder
queryChannelsOverride paginatorOptions.doRequest. See below
onNewMessage, onNewMessageNotification, onAddedToChannel, onRemovedFromChannel, onChannelUpdated, onChannelDeleted, onChannelHidden, onChannelVisible, onChannelTruncated, onChannelMemberUpdated the paginator's filters and sort decide membership and order. For anything else, client.channelManager.addEventHandler(...) or client.on(...)

A prop change no longer queries again. To change the query, set staticFilters or sort on the paginator and call paginator.reload(), or pass a new paginator with a new id. useChannelUpdated is removed, because the channel manager handles channel.updated.

queryChannelsOverride returned Channel[]. Its replacement, doRequest, receives the request the paginator would send and resolves to { items }. Call client.queryChannelsAndHydrate() inside it, so the client stores the channels:

import { ChannelPaginator } from "stream-chat";
import type { StreamChat } from "stream-chat";

export const createChannelPaginator = (client: StreamChat) =>
  new ChannelPaginator({
    client,
    id: "channels:custom-query",
    paginatorOptions: {
      doRequest: async ({ stateOptions, ...request }) => ({
        items: await client.queryChannelsAndHydrate(request, stateOptions),
      }),
    },
  });

The list follows its filters and sort

The list is a projection of its paginator's filters and sort, which every query and every event re-applies:

  • A channel moves by its sort key when an event arrives. Nothing moves a channel to the top by default, so a pinned-first sort keeps pinned channels first. To move a channel to the top for a while, call paginator.boost(cid).
  • A channel that stops matching the filters after an event leaves the list.
  • An empty sort, and a paginator created without one, mean the backend's default order, by the latest message.
  • After a reconnect, the client reloads every loaded page of each registered list, up to 150 channels, and removes the channels no page returned. ChannelList has no reconnect listener of its own. Pulling down on the list still reloads only the first page.
  • The channel preview's typing indicator ignores typing inside threads.

ChannelsContext holds the paginator

useChannelsContext() no longer carries the list's state. It holds the list's configuration, the paginator, and the loadNextPage, refreshList and reloadList functions:

v9 useChannelsContext() v10
channels, hasNextPage, error items, hasMoreTail and lastQueryError in paginator.state
loadingChannels, channelListInitialized items === undefined in paginator.state
loadingNextPage, refreshing props of ChannelListView
forceUpdate removed. It was always 0
loadNextPage(queryType?, retryCount?) loadNextPage(), which does nothing until the first page has loaded
import { Text } from "react-native";
import type { ChannelPaginatorState } from "stream-chat";
import { useChannelsContext, useStateStore } from "stream-chat-react-native";

const channelsSelector = (state: ChannelPaginatorState) => ({
  channels: state.items,
});

export const LoadedChannelCount = () => {
  const { paginator } = useChannelsContext();
  const { channels } = useStateStore(paginator.state, channelsSelector);
  return <Text>{channels?.length ?? 0} channels</Text>;
};

ChannelListView takes the list's state as required props: channels, channelListInitialized, hasNextPage, loadingChannels, loadingNextPage and refreshing. Its loadNextPage is () => Promise<void>, and its forceUpdate is removed. ChannelPreview lost forceUpdate too, and useChannelPreviewData(channel, client) lost its third argument. Its lastMessage is a LocalMessage or undefined.

A channel row re-renders only when its own channel changes. A custom ChannelPreview that reads channel.data or channel.state without subscribing no longer picks up changes through other rows' updates. Subscribe with useStateStore, or with hooks such as useChannelName(channel).

Threads

Thread is a sibling of Channel

A thread is a Thread object from stream-chat. Thread takes it as a required thread prop, renders next to Channel instead of inside it, and renders the children you pass. Opening and closing a thread is up to your app, typically a screen of its own. The v10 behavior is on Thread UI.

In v9:

<Channel channel={channel} thread={thread} threadList>
  <Thread />
</Channel>

In v10:

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

type Props = { onClose: () => void; thread: StreamThread };

export const ThreadScreen = ({ onClose, thread }: Props) => (
  <Thread onThreadDismount={onClose} thread={thread}>
    <MessageList />
    <MessageComposer />
  </Thread>
);

MessageList inside a Thread renders the thread's replies, with the parent message above them. Thread takes the same configuration props as Channel, such as keyboardVerticalOffset, messageActions and audioRecordingEnabled, so pass it what you pass Channel. allowThreadMessagesInChannel moved from Channel to Thread.

onThreadSelect on MessageList, MessageFlashList and ThreadList receives the Thread:

- <MessageList onThreadSelect={(parentMessage) => setThread(parentMessage)} />
+ <MessageList onThreadSelect={(thread) => setThread(thread)} />

- <ThreadList onThreadSelect={({ thread, threadInstance }, channel) => open(threadInstance, channel)} />
+ <ThreadList onThreadSelect={(thread) => open(thread, thread.channel)} />

Where you have only the parent message, for example from a push notification or a deep link, get the thread with client.threads.ensure({ channel, parentMessage }). It returns the Thread the client holds for that message, or creates one. client.threads.get(id) returns a thread the client holds. The ThreadType type is removed.

Where the removed props went:

v9 prop on Thread v10
thread, the parent message thread, the Thread object, now required
threadInstance thread
additionalMessageListProps, additionalMessageFlashListProps, shouldUseFlashList render the list you want as a child, with its props
additionalMessageComposerProps, autoFocus, disabled render MessageComposer as a child, with its props
closeThreadOnDismount onThreadDismount
parentMessagePreventPress a prop of ThreadFooterComponent, true by default
client, closeThread, loadMoreThread, reloadThread removed

The ThreadMessageComposer and MessageList slots are removed from WithComponents. Render the composer and the list you want as children of Thread. They're the only slots v10 removed.

ThreadContext holds the Thread

useThreadContext() returns { thread, allowThreadMessagesInChannel }. thread used to be the parent message. It's now the Thread, or undefined outside a Thread. useThreadContext() no longer throws outside a Thread, and only Thread provides it, so !useThreadContext().thread tells a component that it isn't rendering a thread.

thread.id is the parent message's id, as before. Message fields such as thread.text are in thread.state, as parentMessage. The same applies to the thread of useMessageComposerContext().

v9 useThreadContext() v10
thread, the parent message parentMessage in thread.state
threadMessages items in thread.messagePaginator.state
loadMoreThread(), loadMoreRecentThread() thread.messagePaginator.toTail() and thread.messagePaginator.toHead()
threadHasMore, threadLoadingMore, threadLoadingMoreRecent hasMoreTail and isLoading in thread.messagePaginator.state
reloadThread() Thread.reload()
threadInstance thread
openThread, closeThread, setThreadLoadingMore removed. Your navigation opens and closes threads
parentMessagePreventPress a prop of ThreadFooterComponent
onAlsoSentToChannelHeaderPress moved to useMessagesContext(). Its payload has thread instead of parentMessage

threadList, the flag that told a component it rendered a thread, is removed from useChannelContext(), useMessageContext() and the props of Message, MessageContent, MessageReplies, MessageList, MessageFlashList and MessageComposer. Read useThreadContext().thread instead. MessageItemView, SentToChannelHeader and ShowThreadMessageInChannelButton take a thread prop instead. MessageReplies no longer hides itself inside a thread. MessageItemView hides it there, so a custom layout that renders MessageReplies directly checks useThreadContext().thread itself.

ThreadFooterComponent reads the thread from the context, and its only prop is parentMessagePreventPress.

The thread state in stream-chat moved too. thread.state.replies, Thread.loadNextPage() and Thread.loadPrevPage() are thread.messagePaginator. The thread list's client.threads.state and client.threads.loadNextPage() are client.threads.paginator, and threadsById is client.threads.get(id).

What Thread does

  • Thread doesn't need a Channel above it. It uses thread.channel, and watches that channel once if nothing has queried it.
  • Opening a thread, or pressing the scroll-to-bottom button in one, no longer marks the channel read. The thread marks itself read while it's open, and never marks a thread with no replies.
  • When the first page of replies fails to load, Thread renders LoadingErrorIndicator, with a retry, instead of the list and the composer.

Message lists

MessageList and MessageFlashList read their own paginator, the channel's or, inside a Thread, the thread's. The props that Channel used to fill in are removed:

  • loadMore, loadMoreRecent, hasMore, loadingMore, loadingMoreRecent, threadHasMore, loadMoreThread, loadMoreRecentThread, loadChannelAroundMessage, loading, reloadChannel and threadInstance.
  • targetedMessage, setTargetedMessage, and highlightedMessageId on MessageFlashList.
  • channelUnreadStateStore and setChannelUnreadState.
  • maximumMessageLimit, scrollToFirstUnreadThreshold and threadList.
  • allowSendBeforeAttachmentsUpload, which is pendingUploadsEnabled now.
  • shouldShowUnreadUnderlay, which was never read. Set it on Channel.
  • FlatList and inverted on MessageFlashList, which were never read.

thread is still a prop, typed Thread | undefined instead of the parent message. Inside a Thread you can leave it out. onThreadSelect receives a Thread. To change how a page is fetched, configure the paginator through client.config. useMessageList() takes { paginator } instead of { threadList }.

The component at the older end of the list, FooterComponent on MessageList and HeaderComponent on MessageFlashList, receives loadingMore as a prop. It used to read it from PaginatedMessageListContext:

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

const OlderMessagesSpinner = ({ loadingMore }: { loadingMore?: boolean }) =>
  loadingMore ? <ActivityIndicator /> : null;

export const MyMessageList = () => (
  <MessageList FooterComponent={OlderMessagesSpinner} />
);

The component at the newer end gets no props, and nothing gives it a loading flag for newer messages. Read isLoading from the paginator there. InlineLoadingMoreIndicator and InlineLoadingMoreRecentIndicator take loadingMore and loadingMoreRecent as props, and render nothing without them.

Jumping and highlighting

useTargetedMessage is removed. Jump with the paginator, which loads the message if it isn't loaded and highlights it for focusSignalTtlMs. Read whether a row is the highlighted one with useIsTargetedMessage(messageId):

import { Button } from "react-native";
import {
  DEFAULT_HIGHLIGHT_DURATION,
  useMessagePaginator,
} from "stream-chat-react-native";

export const JumpToMessageButton = ({ messageId }: { messageId: string }) => {
  const paginator = useMessagePaginator();

  return (
    <Button
      onPress={() =>
        paginator.jumpToMessage(messageId, {
          focusReason: "jump-to-message",
          focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
        })
      }
      title="Show message"
    />
  );
};

Read state and unread counts

The SDK marks a channel read on its own, when the user is at the newest message. Most apps can delete their own Channel.markRead() calls. Where you still need one:

  • useMarkRead(channel) returns a markRead() function, the replacement for the markRead that useChannelContext() returned.
  • Channel.markReadViaReporter() marks the channel read outside React. It also calls your markReadRequest handler.
  • Channel.markRead() is the direct request in stream-chat 10. It skips your handler, and throws when read events are turned off.

The unread state moved to channel.messagePaginator.unreadStateSnapshot, a store of UnreadSnapshotState. The ChannelUnreadState type is removed. ScrollToBottomButton lost unreadCount, and UnreadMessagesNotification lost channelUnreadStateStore and unreadCount. Both count unread messages themselves. UnreadMessagesNotification marks the channel read through a new markRead prop, so a custom component that wraps it must pass markRead on, or dismissing it stops marking the channel read.

TypingIndicatorContainer takes threadId instead of thread. See Read state, unread and receipts.

Pruning the list

maximumMessageLimit is now maxLoadedItems in the message paginator's configuration. It caps the number of loaded messages, as before:

import { StreamChat } from "stream-chat";

const client = StreamChat.getInstance("YOUR_API_KEY");

// Every channel's message list. Use `thread` for thread replies, or `messagePaginator` for both.
client.config.set({ channel: { messagePaginator: { maxLoadedItems: 200 } } });

Messages

Message operations

sendReaction, deleteReaction, deleteMessage, removeMessage, retrySendMessage and updateMessage are removed from useMessagesContext(). useMessageOperations() returns them, with the same names:

import { Button } from "react-native";
import type { LocalMessage } from "stream-chat";
import { useMessageOperations } from "stream-chat-react-native";

export const MessageTools = ({ message }: { message: LocalMessage }) => {
  const { deleteMessage, sendReaction } = useMessageOperations();

  return (
    <>
      <Button onPress={() => sendReaction("like", message.id)} title="Like" />
      <Button
        onPress={() => deleteMessage(message, { hard: true })}
        title="Delete"
      />
    </>
  );
};
  • deleteMessage's options are { hard, delete_for_me } instead of { hardDelete, deleteForMe }.
  • updateMessage takes one argument, the message.
  • A custom Message component still receives the six functions as props.
  • Outside React, channel.messageOperations and thread.messageOperations run the same optimistic updates. stream-chat 10 has no *WithLocalUpdate methods, and Channel.sendMessage() and Channel.sendReaction() are plain requests that update nothing locally.

The behavior changed with the move. See Message operations.

  • Reactions are always optimistic. v9 applied them optimistically only with offline support. A reaction that fails for good is rolled back. Remove any optimistic reaction logic of your own, or it applies twice.
  • Edits and deletes show at once. A delete that fails for good is reverted. A hard delete removes the message from the list.
  • Deleting a message whose edit failed deletes it on the server. v9 removed every failed message locally only.
  • A failed send rejects the promise, where v9 swallowed the error. Code of yours that sends must handle the rejection. The built-in composer shows a notification.

Message actions

useMessageActions() and useMessageActionHandlers() take no arguments. They read the contexts of the message they're called in, so call them inside a message, such as in a custom Message or MessageActionList. MessageActionsHookProps is removed.

- const actions = useMessageActions({ channel, client, message, t /* ... */ });
- const handlers = useMessageActionHandlers({ channel, client, message /* ... */ });
+ const actions = useMessageActions();
+ const handlers = useMessageActionHandlers();

useMessageActionHandlers() returns the message's MessageActionHandlerCallbacks, which useMessageContext() also holds as messageActionHandlers.

Message no longer declares handleBan, handleBlockUser, handleCopy, handleDelete, handleDeleteForMe, handleEdit, handleFlag, handleMarkUnread, handleMute, handlePinMessage, handleQuotedReply, handleRetry, messageActions and supportedReactions. Passed to a Message, they never reached the action menu. Set them on Channel or Thread. To change the actions of one message, give messageActions a function. It now runs when a message's menu opens, not on every render of every row. The updateMessage it receives takes one argument. See Actions.

MessageActionList lost dismissOverlay, which it never read. A custom list that needs it reads useMessageContext().dismissOverlay.

Read and delivery receipts

readBy and deliveredToCount are removed from useMessageContext() and from the props of Message and MessageStatus. MessageStatus takes the booleans read and delivered, and works them out itself when you pass neither:

import { Text } from "react-native";
import {
  useIsMessageDeliveredToOthers,
  useIsMessageReadByOthers,
  useMessageContext,
} from "stream-chat-react-native";

export const ReceiptText = () => {
  const { message } = useMessageContext();
  const isRead = useIsMessageReadByOthers({ message });
  const isDelivered = useIsMessageDeliveredToOthers({ message });

  return <Text>{isRead ? "Read" : isDelivered ? "Delivered" : "Sent"}</Text>;
};

For the counts, use the length of useMessageReadData({ message }) or useMessageDeliveredData({ message }). All four hooks need a channel above them.

Other message changes

  • isMessageAIGenerated is a prop of Chat only, and useChatContext() holds it. It's removed from Channel, useMessagesContext(), useMessageContext(), and the props of Message and MessageContent. The AI flag is message.custom?.ai_generated:

    - <Channel channel={channel} isMessageAIGenerated={(message) => !!message.ai_generated}>
    + <Chat client={client} isMessageAIGenerated={(message) => !!message.custom?.ai_generated}>
  • Message lost members, openThread and chatContext, and useMessageContext() lost members. onThreadSelect on Message and in useMessageContext() receives a Thread. MessageFooter and MessageItemView lost members. MessageFooter shows the sender's name on other users' messages whenever the channel's memberCount isn't exactly two, where v9 counted the loaded members and needed more than two.

  • useMessagesContext() lost targetedMessage, initialScrollToFirstUnreadMessage and quotedMessage. Read the quoted message from the composer's state.

  • useMessageContext() has a new isVeryLastMessage, which marks the newest message of the list it renders in.

  • The attachment of Giphy, GiphyImage, URLPreview and URLPreviewCompact is stream-chat's Attachment, with your fields under custom. giphyVersion is typed keyof Images.

  • AIStates is an object of literal values, so [AIStates.Thinking, AIStates.Generating].includes(aiState) no longer compiles. Compare with ===.

  • In ImageGalleryStateStore's assets, created_at is a Date or undefined, channelId a string, and user a UserResponse.

Composer

Sending goes through the message composer

useMessageInputContext().sendMessage() submits the channel's message composer. It sends a new message, or saves the edit while a message is being edited. editMessage is removed from the context. A custom MessageInputProvider value can no longer carry its own sendMessage or editMessage. Register sendMessageRequest and updateMessageRequest handlers, as Channel props that moved shows.

The SDK used to rewrite /mute @Jane Doe and the other moderation commands into user ids. It no longer does, because the server reads the target from the message's mentioned users, and patchMessageTextCommand is removed. To change a message before it's sent, add a composition middleware in a setup function, client.config.setSetupFunction("messageComposer", fn).

A failed send or edit shows as a client.notifications notification of the type messageSendFailed or messageUpdateFailed, from CORE_NOTIFICATION_TYPE.

Pending uploads

allowSendBeforeAttachmentsUpload is now stream-chat's composer setting messageComposer.attachments.pendingUploadsEnabled. It's removed from Channel and useMessageInputContext(). Read it with usePendingUploadsEnabled(). MessageList and MessageFlashList take it as a pendingUploadsEnabled prop. useMessageInputContext() lost doFileUploadRequest, which is messageComposer.attachments.doUploadRequest now.

With enableOfflineSupport, Chat turns it on when nothing else has set it, as v9's prop default did. It does so once, and doesn't turn it off again when offline support goes off. To set it for every channel, register it before the client reaches Chat. To set it for some channels, use a setup function:

import { StreamChat } from "stream-chat";

const client = StreamChat.getInstance("YOUR_API_KEY");

// Every channel.
client.config.set({
  messageComposer: { attachments: { pendingUploadsEnabled: false } },
});

// Livestream channels only. One setup function per key: add this to yours if you have one.
client.config.setSetupFunction("messageComposer", ({ composer }) => {
  if (composer.channel.type !== "livestream") return;
  composer.updateConfig({ attachments: { pendingUploadsEnabled: true } });
});

See Sending with pending uploads.

Attachments that are still uploading

v9 wrote a local file's URI into image_url or asset_url while the file uploaded, and kept the upload's id and file in custom.localId and custom.originalFile. In v10 such an attachment has no URL until its upload finishes. It carries localMetadata instead, with id, file, previewUri and uploadState. A custom component that renders attachments uses the helpers:

import { getAttachmentPreviewUrl, isLocalUploadAttachment } from "stream-chat";
import type { Attachment } from "stream-chat";
import { getPlayableVideoUrl } from "stream-chat-react-native";

export const describeAttachment = (attachment: Attachment) => ({
  // The upload's id while it uploads.
  localId: isLocalUploadAttachment(attachment)
    ? attachment.localMetadata.id
    : undefined,
  // The uploaded URL, or the local preview while it uploads. For images, files and audio.
  previewUrl: getAttachmentPreviewUrl(
    attachment,
    attachment.asset_url,
    attachment.image_url,
  ),
  // What a video player plays. For videos, the preview is a thumbnail image.
  videoUrl: getPlayableVideoUrl(attachment),
});

createAttachmentsCompositionMiddleware, createDraftAttachmentsCompositionMiddleware and localAttachmentToAttachment are removed. The composer installs stream-chat's createAttachmentsCompositionMiddleware by default. A draft keeps only the attachments that finished uploading. DefaultAttachmentData is empty, and AudioUpload and LocalAudioAttachmentType lost localId, originalFile, latitude and longitude. In AudioAttachmentType, AudioUpload and LocalAudioAttachmentType, waveform_data, mime_type, file_size and duration moved under custom.

isLocalUrl() checks the URL's scheme, and returns false for undefined. makeImageCompatibleUrl() returns undefined for undefined. Both threw on undefined in v9.

Other composer changes

  • The composer loads link previews by default where the channel type enriches URLs. To turn them off, set client.config.set({ messageComposer: { linkPreviews: { enabled: false } } }).
  • The poll button follows messageComposer.config.polls.enabled, which is true until the server's settings arrive. Hide it with client.config.set({ messageComposer: { polls: { enabled: false } } }).
  • The input's maxLength follows messageComposer.config.text.maxLengthOnSend, so a smaller value you register caps the input too.
  • AutoCompleteInput's and InputView's t is a StreamTFunction.
  • In CommandSuggestionItem, args, description, name and set are required strings, and created_at and updated_at are nanosecond timestamps.
  • A composer's contextType is 'channel', 'thread' or 'message'. 'legacy_thread' is removed, with LocalMessageWithLegacyThreadId and its legacyThreadId. A thread's composer is always its Thread's messageComposer, with the contextType 'thread', so a setup function or middleware that checked for 'legacy_thread' checks for 'thread'.

Offline storage

The offline database's version, SqliteClient.dbVersion, is 18 in v10, up from 16. The first launch after the upgrade deletes and rebuilds the database. Channels and messages load again from the server. Actions the user queued while offline in v9, such as unsent messages, are lost with it. Date columns that hold server data store nanosecond integers instead of ISO strings. See Offline support.

The rest affects only a custom AbstractOfflineDB, a subclass of OfflineDB, or code that calls OfflineStoreApis or reads the database:

  • getChannelsForQuery takes { userId, options }, with the filters in options.filter_conditions and the sort in options.sort. It returns { channels, predefinedFilter } instead of an array. upsertCidsForQuery takes a predefinedFilter to store with the query.
  • OfflineDB lost handleOfflineFailedUpdateMessagePendingTask and mergeFailedMessageUpdateIntoPendingSendMessage. Move overrides of them to handleUpdateMessagePendingTask. hardDeleteMessages, upsertMessageBehindChannelGuard and queriesBehindChannelGuard are new. Its event handlers and upserts take stream-chat 10 types.
  • client.offlineDb.syncManager.syncStatus is isSynced.
  • OfflineStoreApis functions take userId instead of currentUserId. deleteReactionsForMessage is removed. getChannelsForFilterSort returns { channels, predefinedFilter }, sorts are SortParamRequest[], and getAppSettings returns a GetApplicationResponse. deleteMessagesForChannel takes truncated_at as a nanosecond timestamp.
  • insertReaction and updateReaction skip a reaction whose message isn't stored, where v9's write failed.
  • channelExists answers correctly. In v9 it was always true.
  • SqliteClient.logger is a stream-chat Sink, called as before.
  • A timestamp you read from the tables yourself is already in nanoseconds. Brand it with asTimestampNS(), not msToNs().

Other changes

These come up less often:

  • getLastReceivedMessage(messages) is removed. It did what messages.find((m) => m.status !== "failed") does. reduceMessagesToString, findInMessagesById and findInMessagesByDate are removed, and replaced by channel.messagePaginator.getItem(id) and channel.messagePaginator.jumpToMessage(id).
  • useQueryReminders() returns ReminderResponseData[], with nanosecond timestamps.
  • usePollState, usePollStateStore, usePollAnswersPagination and usePollOptionVotesPagination use the renamed poll types. addComment() resolves to a PollVoteResponse, and endVote() to a PollResponse or undefined. In the poll components, PollOption is PollOptionResponseData, PollAnswer and PollVote are PollVoteResponseData, and CreatePollOption.error is a PollComposerValidationError.
  • useMessagePaginator() returns a MessagePaginator, never undefined.
  • useCreateMessagesContext, useCreateThreadContext, useCreateMessageInputContext and useCreateMessageContext take the new, smaller context shapes.
  • LoadingErrorIndicator's error is an Error. error={true} no longer compiles.
  • useInAppNotificationsState, deprecated since v9, still reads a store of its own that no SDK component shows. Hooks left out of this reference maps it to the notification hooks.
  • ProgressControl lost width, which it never read.
  • defaultTranslatorFunction returns the English copy instead of the key.
  • LastMessageType is still LocalMessage | MessageResponse, but both types changed with stream-chat 10.
  • Imports from stream-chat-react-native-core/src/... or lib/... of modules that were never exported from the package root may fail. ChannelsStateProvider, useChannelsStateContext, useChannelState, ActiveChannelsRefContext, useChannelDataState, ChannelUnreadStateStore, MessagePreviousAndNextMessageStore, the channel list's pin and archive sort helpers, useCreateMessageComposer and the utils/i18n/* modules are deleted. The functions of utils/i18n/* are exported from the package root.
  • filterTypingUsers, a deep import, takes threadId instead of thread.
  • No other WithComponents slot or icon was added, removed or renamed, and the theme's shape is the same. The props and contexts that the slots receive did change, as the sections above describe, so check each custom component you register against them.