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. Getting the object comes first: Providers and context hooks explains which client, channel, thread or composer a component gets.

Here is an unread badge for a channel list row:

import { Text } from "react-native";
import type { Channel, ChannelStateData } from "stream-chat";
import { useChatContext, useStateStore } from "stream-chat-react-native";

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

// Rendered in a custom ChannelPreview, once for each channel list row.
export const UnreadBadge = ({ channel }: { channel: Channel }) => {
  const { client } = useChatContext();
  const { read } = useStateStore(channel.state, readSelector);

  const unread = client.userId
    ? (read[client.userId]?.unread_messages ?? 0)
    : 0;
  if (!unread) return null;

  return <Text>{unread}</Text>;
};
  1. channel is the channel of the row the badge renders in: the ChannelPreview override gets it as a prop, and passes it on. Providers and context hooks explains where each component gets its channel.
  2. readSelector picks the read field from channel.state, which holds every member's read state.
  3. useStateStore subscribes to channel.state. When the channel's state changes, it runs the selector again and re-renders UnreadBadge only if read is a different value.
  4. The component works out the current user's count from read after selecting it.

The useStateStore rules

  1. 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.
  2. 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.
  3. Derive values after selecting. Select the fields as they are and compute from them in the component, as UnreadBadge does. A selector that maps or filters builds a new array on every run, so the component re-renders on every change to the store.
  4. Import useStateStore from stream-chat-react-native (or stream-chat-expo). It is the one the SDK's components use.

The store you pass can be undefined, for example thread?.state in a component that also renders outside a thread. The hook then returns undefined instead of the selection, so you don't need to call it conditionally:

import { Text } from "react-native";
import type { ThreadState } from "stream-chat";
import { useStateStore, useThreadContext } from "stream-chat-react-native";

const replyCountSelector = (state: ThreadState) => ({
  replyCount: state.replyCount,
});

export const ReplyCount = () => {
  const { thread } = useThreadContext();
  // Outside a thread, the store is undefined and so is the selection.
  const { replyCount } = useStateStore(thread?.state, replyCountSelector) ?? {};

  if (replyCount === undefined) return null;
  return <Text>{replyCount} replies</Text>;
};

A selector that depends on a prop:

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

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 <Text>{member?.channel_role ?? "not a member"}</Text>;
};

Pitfalls

Selector identity

An inline selector is a new function on every render, so the hook unsubscribes and subscribes again each time:

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

// Resubscribes on every render.
export const TypingCountInline = () => {
  const channel = useChannel();
  const { typing } = useStateStore(
    channel.state,
    (state: ChannelStateData) => ({
      typing: state.typing,
    }),
  );
  return <Text>{Object.keys(typing).length}</Text>;
};

// Subscribes once.
const typingSelector = (state: ChannelStateData) => ({ typing: state.typing });

export const TypingCount = () => {
  const channel = useChannel();
  const { typing } = useStateStore(channel.state, typingSelector);
  return <Text>{Object.keys(typing).length}</Text>;
};

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.

Returning the slice itself

A selector such as (state) => state.members compiles, but the component doesn't re-render on every change, for example when a member leaves. Wrap the slice in an object, (state) => ({ members: state.members }), as rule 2 says.

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 one in stream-chat-react-native compares the keys of the previous selection with ===, and the one in @stream-io/state-store compares the keys of the new selection with Object.is. With selectors that follow the rules above, both see the same changes. Import the one from stream-chat-react-native.

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.state is itself the channel's store. There is no channel.state.state.
  • client.notifications.store holds the notifications.
  • client.mutedUsersStore holds the users the current user has muted. useMutedUsers() reads it.
  • channel.messageReceiptsTracker.snapshotStore holds delivery and read receipts.
  • configState holds 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) and messageFocusSignal (the message to scroll to and highlight).
  • The thread list is client.threads.paginator.state. client.threads.state holds the unread thread count and the unseen threads.

The fields of each store are in Reference.

Context hooks that throw

useChannel(), useChannelContext() and useMessagePaginator() throw outside a Channel or a Thread, and so do the hooks built on them. useChatContext() throws outside Chat. A component that renders both inside and outside a channel, a channel list row for example, takes the channel as a prop instead: the channel hooks such as useChannelName(channel) and useChannelMemberCount(channel) accept it as an argument. Providers and context hooks lists which hook throws where.

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

Every hook is documented in Reference, one page per state area:

Where no hook covers a store, read it with useStateStore.