SDK State Management

Most application state that drives the chat UI lives in the low-level client (StreamChat) on client and channel instances. That state is not reactive. The React SDK makes it reactive by listening to client events and syncing changes into React state. Optimistic updates, when applicable, are local to the React SDK.

This is how the SDK state pipeline looks like behind the scenes:

sequenceDiagram
  box Stream Chat Client
    participant Client State
    participant WebSocket Client
  end

  box Stream React SDK
    participant UI Components
  end

  Client State->>WebSocket Client: attach listeners to keep the state up-to-date
  UI Components->>WebSocket Client: attach listeners to keep UI up-to-date
  WebSocket Client->>Client State: receive a WebSocket message and trigger listeners
  Client State->>Client State: update client state
  WebSocket Client->>UI Components: receive a WebSocket message and trigger listeners
  UI Components->>Client State: reach for updated client state
  UI Components->>UI Components: update UI

Active Channel & Channel State

ChatContext holds the active channel and the client instance passed to Chat. Before you can access the reactive channel state you'll need to set the channel instance as active. The channel becomes active when:

  • it’s selected in ChannelList (if you use the default setup, you already have ChannelList)

  • it's passed to the channel prop of the Channel component

    import { useState, useEffect } from "react";
    import { Channel, useChatContext } from "stream-chat-react";
    
    export const ChannelWrapper = ({
      channelId,
      channelType = "messaging",
      children,
    }) => {
      const [activeChannel, setActiveChannel] = useState(undefined);
      const { client } = useChatContext();
    
      useEffect(() => {
        if (!channelId) return;
    
        const channel = client.channel(channelType, channelId);
    
        setActiveChannel(channel);
      }, [channelId, channelType]);
    
      return <Channel channel={activeChannel}>{children}</Channel>;
    };
  • it's set as active by calling the setActiveChannel function coming from the ChatContext (this function is used by ChannelList behind the scenes)

    import { useEffect } from "react";
    import {
      useCreateChatClient,
      useChatContext,
      Chat,
      Channel,
    } from "stream-chat-react";
    
    const ActiveChannelSetter = ({ channelId, channelType }) => {
      const { client, setActiveChannel } = useChatContext();
    
      useEffect(() => {
        const channel = client.channel(channelType, channelId);
        setActiveChannel(channel);
      }, [channelType, channelId]);
    
      return null;
    };
    
    const App = () => {
      const client = useCreateChatClient(userData);
    
      if (!client) return <div>Loading...</div>;
    
      return (
        <Chat client={client}>
          <ActiveChannelSetter channelId="random" channelType="messaging" />
          <Channel>{"...other components..."}</Channel>
        </Chat>
      );
    };

Best Practices

  • Treat client and channel state as non-reactive; rely on SDK contexts.
  • Choose one active-channel strategy (ChannelList, channel prop, or setActiveChannel), not multiple.
  • Keep selectors stable and memoized to avoid re-subscriptions.
  • Read only the state slice you need for performance.
  • Avoid heavy computations inside selectors or render paths.

You can use either channel prop on the Channel component or setActiveChannel function. You cannot use both at the same time.

Currently active channel state and channel instance can be accessed through the ChannelStateContext with the help of the useChannelStateContext hook - meaning any component which is either direct or indirect child of the Channel component can access such state.

The example below shows how to access members and channel from channel state:

import { useEffect } from "react";
import { Channel, useChannelStateContext } from "stream-chat-react";

const MembersCount = () => {
  const { members, channel } = useChannelStateContext();

  useEffect(() => {
    console.log(`Currently active channel changed, channelId: ${channel.id}`);
  }, [channel]);

  return <div>{Object.keys(members).length}</div>;
};

const ChannelWrapper = () => (
  <Channel>
    <MembersCount />
  </Channel>
);

Channel List State

ChannelList is a standalone component that manages the channel list. Access loaded channels via ChannelListContext and the useChannelListContext hook. Any child of ChannelList (for example, Channel Preview) can access this state.

import { ChannelList, ChannelPreviewMessenger } from "stream-chat-react";
import type { ChannelListProps } from "stream-chat-react";

const CustomPreviewUI = (props) => {
  const { channels } = useChannelListContext();

  return <ChannelPreviewMessenger {...props} />;
};

export const CustomChannelList = (props: ChannelListProps) => {
  return <ChannelList Preview={CustomPreviewUI} {...props} />;
};

Thread and ThreadManager

With the new threads feature, state management moved to a subscribable POJO with selector-based access to improve DX when rendering data from StreamChat.

This change is currently only available within StreamChat.threads but will be reused across the whole SDK later on.

Why POJO (State Object)

The SDK exposes a lot of data, and different apps need different slices of it. That data now lives in a state object, and selectors let you pick exactly what you need without extra overhead.

What are Selectors

Selectors run whenever the state object changes and should return only the slice needed by a component. Don’t do heavy computation or create new arrays/objects on each run; use pre-built hooks with computed values or build memoized selectors as needed.

Rules of Selectors

  1. Selectors should return a named object.
const selector = (nextValue: ThreadManagerState) => ({
  unreadThreadsCount: nextValue.unreadThreadsCount,
  active: nextValue.active,
  lastConnectionDownAt: nextvalue.lastConnectionDownAt,
});
  1. Selectors should live outside component scope or be memoized if they depend on external data (for example, userId for read). Unstable selectors cause unnecessary unsubscribe/resubscribe cycles and hurt performance.
// ❌ not okay
const Component1 = () => {
  const { latestReply } = useThreadState((nextValue: ThreadState) => ({
    latestReply: nextValue.latestReplies.at(-1),
  }));

  return <div>{latestReply.text}</div>;
};

// ✅ okay
const selector = (nextValue: ThreadState) => ({
  latestReply: nextValue.latestReplies.at(-1),
});

const Component2 = () => {
  const { latestReply } = useThreadState(selector);

  return <div>{latestReply.text}</div>;
};

// ✅ also okay
const Component3 = ({ userId }: { userId: string }) => {
  const selector = useCallback(
    (nextValue: ThreadState) => ({
      unreadMessagesCount: nextValue.read[userId].unread_messages,
    }),
    [userId],
  );

  const { unreadMessagesCount } = useThreadState(selector);

  return <div>{unreadMessagesCount}</div>;
};
  1. Break your components down to the smallest reasonable parts that each take care of the apropriate piece of state if it makes sense to do so.

Accessing Reactive State

Our SDK currently allows to access two of these state structures - in Thread and ThreadManager instances under state property.

Vanilla

import { StreamChat } from "stream-chat";

const client = new StreamChat(/*...*/);

// calls console.log with the whole state object whenever it changes
client.threads.state.subscribe(console.log);

let latestThreads;
client.threads.state.subscribeWithSelector(
  // called each time theres a change in the state object
  (nextValue) => ({ threads: nextValue.threads }),
  // called only when threads change (selected value)
  ({ threads }) => {
    latestThreads = threads;
  },
);

// returns lastest state object
const state = client.threads.state.getLatestValue();

const [thread] = latestThreads;

// thread instances come with the same functionality
thread?.state.subscribe(/*...*/);
thread?.state.subscribeWithSelector(/*...*/);
thread?.state.getLatestValue(/*...*/);

useStateStore Hook

For ease of use, the React SDK provides the useStateStore hook, which wraps the StateStore.subscribeWithSelector API for React applications.

import { useStateStore } from "stream-chat-react";
import type { ThreadManagerState } from "stream-chat";

const selector = (nextValue: ThreadManagerState) => ({
  threads: nextValue.threads,
});

const CustomThreadList = () => {
  const { client } = useChatContext();
  const { threads } = useStateStore(client.threads.state, selector);

  return (
    <ul>
      {threads.map((thread) => (
        <li key={thread.id}>{thread.id}</li>
      ))}
    </ul>
  );
};

useThreadState and useThreadManagerState

Both of these hooks use useStateStore under the hood but access their respective states through specific contexts: for ThreadManagerState it's ChatContext (accessing client.threads.state), and for ThreadState it's ThreadListItemContext first and ThreadContext second, meaning that the former is prioritized. While these hooks make it slightly easier for integrators to reach reactive state

// memoized or living outside component's scope
const threadStateSelector = (nextValue: ThreadState) => ({
  replyCount: nextValue.replyCount,
});
const threadManagerStateSelector = (nextValue: ThreadState) => ({
  threadsCount: nextValue.threads.length,
});

const MyComponent = () => {
  const { replyCount } = useThreadState(threadStateSelector);
  const { threadsCount } = useThreadManagerState(threadManagerStateSelector);

  return null;
};

Conclusion

This guide covers the biggest and most important state stores, see other React stateful contexts exported by our SDK for more information.

Mentioned in this article:

Other data/action providers: