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

Channel and thread side by side

This recipe shows the open channel with a thread beside it. The channel list sits on the left, the channel takes the space in the middle, and the thread opens in a 400px column on the right. Closing the thread gives its space back to the channel.

It starts from the app in Add threads, which already has a slot for each, and adds four things:

  • an empty state in the channel's place until the user picks a channel;
  • a rule that puts every thread in the channels view in the thread slot;
  • the hook that tells the thread it's on screen, so its new replies are marked read;
  • a fixed width for the thread.

A layout with two slots

src/App.tsx
import { useEffect } from "react";
import { ChannelPaginator } from "stream-chat";
import type { ChannelFilters, SortParamRequest } from "stream-chat";
import {
  ChannelHeader,
  ChannelNavigation,
  Chat,
  MessageComposer,
  MessageList,
  ThreadHeader,
  ThreadList,
  useCreateChatClient,
  useThreadContext,
} from "stream-chat-react";
import {
  ChannelSlot,
  ChatView,
  ChatViewEmptyPlaceholder,
  ThreadSlot,
  useActiveThread,
} from "stream-chat-react/slot-layout";
import type {
  ChatViewProps,
  LayoutDescriptor,
} from "stream-chat-react/slot-layout";
import "stream-chat-react/dist/css/index.css";
import "./layout.css";

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

const filters: ChannelFilters = {
  members: { $in: [userId] },
  type: "messaging",
};
const sort: SortParamRequest[] = [{ direction: -1, field: "last_message_at" }];

// The channels view has a slot for the open channel and, after it, one for a
// thread opened from it. The threads view has one slot, for the thread picked
// in the list.
const layouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["channel", "thread"] },
  { id: "threads", slots: ["thread"] },
];

// In the channels view, every thread opens in the "thread" slot.
const viewActionSlotResolvers: ChatViewProps["viewActionSlotResolvers"] = {
  channels: { openThread: () => "thread" },
};

// Marks the thread as on screen while the window is visible and focused.
const ActiveThread = () => {
  const thread = useThreadContext();
  useActiveThread({ activeThread: thread });
  return null;
};

const ThreadPanel = () => (
  <ThreadSlot slot="thread">
    <ActiveThread />
    <ThreadHeader />
    <MessageList />
    <MessageComposer />
  </ThreadSlot>
);

const ChannelsView = () => (
  <>
    <ChatView.Selector />
    <ChannelNavigation />
    <ChannelSlot fallback={<ChatViewEmptyPlaceholder />} slot="channel">
      <ChannelHeader />
      <MessageList />
      <MessageComposer />
    </ChannelSlot>
    <ThreadPanel />
  </>
);

const ThreadsView = () => (
  <>
    <ChatView.Selector />
    <ThreadList />
    <ThreadPanel />
  </>
);

const App = () => {
  const client = useCreateChatClient({
    apiKey,
    tokenOrProvider: userToken,
    userData: { id: userId },
  });

  useEffect(() => {
    if (!client) return;
    const paginator = new ChannelPaginator({
      client,
      filters,
      id: "my-channels",
      sort,
    });
    client.channelManager.insertPaginator({ paginator });
    return () => {
      client.channelManager.removePaginator(paginator);
    };
  }, [client]);

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

  return (
    <Chat client={client}>
      <ChatView
        layouts={layouts}
        viewActionSlotResolvers={viewActionSlotResolvers}
        views={{ channels: <ChannelsView />, threads: <ThreadsView /> }}
      />
    </Chat>
  );
};

export default App;
src/layout.css
html,
body,
#root {
  height: 100%;
}

body {
  margin: 0;
}

.str-chat {
  --str-chat__channel-list-width: 320px;
  --str-chat__thread-list-width: 320px;
}

/* Beside the channel, the thread is a 400px column. The channel takes the rest. */
.str-chat__chat-view__channels > .str-chat__thread-container {
  flex: 0 0 400px;
}

layouts gives the channels view two slots, channel and then thread. The view's content places them side by side:

  • ChannelSlot renders the channel in the channel slot. Until the user picks one, it renders its fallback: ChatViewEmptyPlaceholder is the SDK's empty state, "No chat selected".
  • ThreadSlot renders the thread in the thread slot, after the channel. While no thread is open, it renders nothing, so the channel takes the full width.

ThreadPanel is in both views. In the threads view, its thread slot is the view's only slot, and the thread fills the space beside the thread list.

In the CSS, the selector .str-chat__chat-view__channels matches only the channels view, so only the thread beside a channel gets the fixed width. Workspace with ChatView explains views and slots in full.

Opening a thread beside the channel

The replies button under a message, and the reply actions on it, open its thread. Here is what happens when the user clicks "3 replies" under a message in the open channel:

  1. The button calls openThread with the channel and the message. openThread gets the message's thread from client.threads, which creates it if it doesn't exist yet.
  2. A thread opens in the view that's shown, here the channels view. viewActionSlotResolvers picks its slot: thread.
  3. ThreadSlot renders a Thread for it, around the header, the message list and the composer. The thread loads its replies.
  4. ActiveThread calls useActiveThread. The SDK doesn't tell a thread it's on screen; this hook does, while the window is visible and focused. Then the thread's new replies are marked read, and a thread that went out of date, after a lost connection for example, reloads.

Without viewActionSlotResolvers, ChatView picks the slot by its own rules. While a channel is open, they pick thread too. With no channel open, they would pick the first empty slot, channel, where ChannelSlot doesn't show a thread. The resolver keeps a thread opened from your own code, such as a notification, beside the empty state.

Opening another thread replaces the one beside the channel, because that slot already holds a thread. The other ways to open a thread, and how to add your own button for it, are in Opening a thread.

Closing the thread

Three things close the thread beside the channel:

  • The close button in the thread's header. ThreadHeader renders it on a thread beside a channel. It empties the thread slot. ThreadSlot then renders nothing, and the channel takes the full width.
  • Opening a channel. A click on a channel in the list or in the search results closes the thread, even when it's the channel that's already open.
  • Your own control. useCloseThread() returns the function the close button calls. Back and close shows a custom thread header that uses it.

In the threads view, the thread is the view's main panel, so its header has no close button. The user picks another thread or switches views.

On a phone there's no room for two columns. Below 768px, the SDK's stylesheet also hides the channel list. Single-pane mobile with back button shows one panel at a time, and Responsive layouts lays the thread over the channel instead.