Reading state in components
A component reads state with useStateStore(store, selector). The hook subscribes to one store of a state-layer object, and re-renders the component when the values the selector picks change. What a store is, and which objects have one, is on Reactive state.
Here is an unread badge for the current channel:
import type { ChannelStateData } from "stream-chat";
import { useChannel, useChatContext, useStateStore } from "stream-chat-react";
const readSelector = (state: ChannelStateData) => ({ read: state.read });
export const UnreadBadge = () => {
const { client } = useChatContext();
const channel = useChannel();
const { read } = useStateStore(channel.state, readSelector);
const unread = client.userID
? (read[client.userID]?.unread_messages ?? 0)
: 0;
if (!unread) return null;
return <span className="unread-badge">{unread}</span>;
};useChannel()finds the channel: theChannel's, or the thread's channel inside aThread.readSelectorpicks thereadfield fromchannel.state, which holds every member's read state.useStateStoresubscribes tochannel.state. When the channel's state changes, it runs the selector again and re-rendersUnreadBadgeonly ifreadis a different value.- The component works out the current user's count from
readafter selecting it.
Which owner a hook reads
The context hooks find their owner from where the component sits, so the same component works in a channel and in a thread:
| Hook | Inside a Channel |
Inside a Thread |
|---|---|---|
useChannel() |
the channel | the thread's channel |
useThreadContext() |
undefined |
the thread |
useMessageComposerController() |
channel.messageComposer |
thread.messageComposer |
useMessagePaginator() |
channel.messagePaginator |
thread.messagePaginator |
Two more cases:
- A composer you supply. Below a
MessageComposerControllerProvider,useMessageComposerController()returns the composer that provider was given, for example the one editing a message inline. - A poll. Inside a poll in a message,
usePollContext()returns{ poll }. Read its state frompoll.state.
useMessagePaginator() picks the message list the way the SDK's own message lists do: the thread's when there is one, otherwise the channel's.
import type { MessagePaginatorState } from "stream-chat";
import { useMessagePaginator, useStateStore } from "stream-chat-react";
const itemsSelector = (state: MessagePaginatorState) => ({
items: state.items,
});
export const LoadedMessageCount = () => {
const paginator = useMessagePaginator();
const { items } = useStateStore(paginator.state, itemsSelector);
return <span>{items?.length ?? 0} messages loaded</span>;
};The useStateStore rules
- Define selectors at module scope. The hook resubscribes whenever it gets a different selector function. When a selector needs a prop, wrap it in
useCallback. - Return an object with fixed keys. Write
(state) => ({ items: state.items }). Never return the slice itself ((state) => state.items), and never a primitive ((state) => state.isLoading): the hook compares the returned object key by key. - Derive values after selecting. Select the fields as they are and compute from them in the component, as
UnreadBadgedoes. A selector that maps or filters builds a new array on every run, so the component re-renders on every change to the store. - Import
useStateStorefromstream-chat-react. It is the one the SDK's components use.
The store may be undefined, for example before the object that holds it exists. The hook then returns undefined, so you don't need a conditional hook call.
A selector that depends on a prop:
import { useCallback } from "react";
import type { ChannelStateData } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react";
export const MemberRole = ({ userId }: { userId: string }) => {
const channel = useChannel();
const selector = useCallback(
(state: ChannelStateData) => ({ member: state.members[userId] }),
[userId],
);
const { member } = useStateStore(channel.state, selector);
return <span>{member?.channel_role ?? "not a member"}</span>;
};Pitfalls
Selector identity
An inline selector is a new function on every render, so the hook unsubscribes and subscribes again each time:
import type { ChannelStateData } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react";
// Resubscribes on every render.
export const TypingCountInline = () => {
const channel = useChannel();
const { typing } = useStateStore(
channel.state,
(state: ChannelStateData) => ({
typing: state.typing,
}),
);
return <span>{Object.keys(typing).length}</span>;
};
// Subscribes once.
const typingSelector = (state: ChannelStateData) => ({ typing: state.typing });
export const TypingCount = () => {
const channel = useChannel();
const { typing } = useStateStore(channel.state, typingSelector);
return <span>{Object.keys(typing).length}</span>;
};The same applies to the filter you pass to useNotifications and useSystemNotifications: the hook builds its selector from it, so an inline filter resubscribes on every render too. Define it at module scope.
Selections that go stale
A selector that returns the slice itself compiles, but misses changes. Take (state) => state.members, an object keyed by user ID:
- An added member is missed.
useStateStorecompares the previous selection's keys, and the previous selection has no key for the new member. - A removed member is missed. The store only notifies the hook when the new selection's keys differ, and the new selection no longer has the removed key.
(state) => state.items fails the same way: a message added at the end, or the last one removed, doesn't re-render the component. Wrap the slice in an object, (state) => ({ members: state.members }), and the comparison is on the slice as a whole.
Two useStateStore hooks
@stream-io/state-store, the package the stores come from, has its own useStateStore in @stream-io/state-store/react-bindings. The two differ:
- The one in
stream-chat-reactcompares the keys of the previous selection with===. The one in@stream-io/state-storecompares the keys of the new selection withObject.is. - Only the one in
stream-chat-reactsupports server rendering.
With selectors that follow the rules above, both see the same changes. Import the one from stream-chat-react.
Getters that aren't reactive
Many objects have getters that read a store's current value. Reading one during render subscribes to nothing: the component shows the new value only when something else re-renders it.
| Getter | Subscribe to |
|---|---|
channel.data |
channel.state, field data |
channel.state.unreadCount |
channel.state, field read, as in UnreadBadge |
channel.state.members, .read, .typing, .watchers |
channel.state, the field of the same name |
thread.ownUnreadCount |
thread.state, field read (unreadMessageCount per user) |
messagePaginator.isViewingLive |
messagePaginator.liveViewState |
client.user |
no store holds the current user yet |
Store names vary
Most stores are called state, but not all of them:
channel.stateis itself the channel's store. There is nochannel.state.state.client.notifications.storeholds the notifications.channel.messageReceiptsTracker.snapshotStoreholds delivery and read receipts.configStateholds an object's settings, on the channel, the composer, the paginators and the other configurable objects.- A message paginator has extra stores next to
state:unreadStateSnapshot(the unread boundary),liveViewState,aggregateState(the latest message) andmessageFocusSignal(the message to scroll to and highlight). - The thread list is
client.threads.paginator.state.client.threads.stateholds the unread thread count and the unseen threads.
The fields of each store are in Reference → State.
Context hooks that throw
useChannel() throws outside a Channel or a Thread, and so do the hooks built on it, such as useMessageComposerController() and useChannelCapabilities(). useChatContext() throws outside Chat. A component that renders both inside and outside a channel, a channel-list row for example, reads useChannelInstanceContext() instead: outside a Channel it returns {}.
import { useChannelInstanceContext } from "stream-chat-react";
export const ChannelName = () => {
const { channel } = useChannelInstanceContext();
if (!channel) return null;
return <span>{channel.cid}</span>;
};The right owner inside threads
Inside a Thread, useChannel() returns the thread's channel, so channel.messagePaginator and channel.messageComposer are the channel's messages and composer, not the thread's. Read the composer with useMessageComposerController(), and the message list with useMessagePaginator().
Batched message-list updates
A message paginator's state publishes at most once every 500 ms by default, so a burst of events causes about two renders a second instead of one per event. The user's own sends skip the wait and show at once. A component that reads state.items can therefore lag up to half a second behind an event. To react to each event as it arrives, listen with Channel.on() (see Events). The interval is a setting of the paginator: see Message paginators.
Hooks by state area
The hooks below are exported today. Each table links to its reference page. Where no hook covers a store, read it with useStateStore.
State and entities
Reference: State and entity hooks.
| Hook | Reads |
|---|---|
useStateStore |
any store, through your selector |
useChatContext |
client, channelManager, searchController, mutes |
useChannel |
the channel of the nearest Channel or Thread |
useChannelInstanceContext |
the Channel's channel, or {} |
useThreadContext |
the Thread's thread, or undefined |
useThreadListItemContext |
the thread of a ThreadList row |
useIsUserMuted |
whether a user is in mutes |
Loading data
Reference: Loading data hooks.
| Hook | Reads |
|---|---|
useChannelListContext |
inside ChannelLists, the first paginator of client.channelManager |
useChannelPaginatorState |
a ChannelPaginator's state: channels, has more, loading, error |
useMessagePaginator |
the message list of the Thread, otherwise of the Channel |
useThreadList |
nothing: it reloads client.threads and keeps it active while visible |
useSearchContext, useSearchSourceResultsContext |
the search controller, and the source whose results are rendered |
useChannelMembersSearch, useChannelMediaSearch, useChannelFilesSearch, usePinnedMessagesSearch |
channel details searches, from stream-chat-react/channel-detail |
For the messages of a message list, use useStateStore on the paginator from useMessagePaginator(), as LoadedMessageCount does. For the thread list, use useStateStore(client.threads.paginator.state, selector).
Channels and threads
Reference: Channel and thread hooks.
| Hook | Reads |
|---|---|
useChannelCapabilities |
channel.state ownCapabilities, as a Set |
useChannelMembersState |
channel.state members |
useChannelMembershipState |
channel.state membership: the current user's own membership |
useChannelDisplayName |
the channel's display name |
useChannelPreviewInfo |
the channel's display title and image |
useCooldownRemaining |
channel.cooldownTimer.state: seconds left in slow mode |
useIsCooldownActive |
channel.cooldownTimer.state: whether the user waits in slow mode |
useLastReadData |
who has read each message |
For anything else in channel.state, thread.state or client.threads.state, use useStateStore with your own selector.
Connection
Reference: Connection hooks.
| Hook | Reads |
|---|---|
useWSConnectionState |
client.wsConnection.state: the socket |
useNetworkConnectionState |
client.networkConnection.state: the device's network |
useNetworkConnectionStateSelector |
a selection of client.networkConnection.state |
useReportLostConnectionSystemNotification |
both, to publish the lost-connection notification, as Chat does |
Writing messages
Reference: Composer hooks.
| Hook | Reads |
|---|---|
useMessageComposerController |
the composer: supplied, the thread's or the channel's |
useMessageComposerControllerContext |
the composer a MessageComposerControllerProvider supplies |
useMessageComposerContext |
the MessageComposer component's own context |
useAttachmentManagerState |
attachmentManager.state and the composer's attachment settings |
useAttachmentsForPreview |
the attachments to show as previews |
useAttachmentUploadState, useAttachmentsUploadState |
upload progress of one or several attachments |
useCanCreatePoll |
whether pollComposer holds a poll that can be created |
useMessageComposerHasSendableData, useMessageContentIsEmpty |
whether there is something to send |
useMessageComposerCommands |
the commands available in the composer |
useMessageComposerSubmitFn, useMessageComposerBindings |
the submit function, and the bindings MessageComposer puts in its context |
For the composer's other stores (messageComposer.state, textComposer.state, pollComposer.state, locationComposer.state, linkPreviewsManager.state), use useStateStore.
Notifications, reminders, polls and location
| Hook | Reads | Reference |
|---|---|---|
useNotifications, useSystemNotifications, useNotificationApi, useNotificationTarget |
client.notifications.store, and the panel of the caller |
Notification hooks |
useMessageReminder |
client.reminders.state, for one message |
Reminder hooks |
usePollContext, usePollAnswerPagination, usePollOptionVotesPagination |
the poll in context, and its answers and votes | Poll hooks |
useLiveLocationSharingManager |
a live location sharing manager it creates | Location hooks |
For a poll's own state, use useStateStore(poll.state, selector) with the poll from usePollContext().
Used in: every page that renders state, and the Reference → Hooks pages.