Channel and thread side by side
This recipe shows the open channel with a thread beside it.
What you'll build
- A three-column layout with the thread on the right
- A "No chat selected" empty state
Preview

Approach: Compose. Choosing where to start explains the approaches.
Starts from: the base app. The page shows the whole app, because the layout replaces the base app's views.
The base app already has a slot for the channel and one for the thread.
A layout with two slots
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 "./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;/* The SDK's stylesheet in a layer, so the overrides below win over it. */
@layer stream, stream-overrides;
@import "stream-chat-react/dist/css/index.css" layer(stream);
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
}
@layer stream-overrides {
.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:
ChannelSlotrenders the channel in thechannelslot. Until the user picks one, it renders itsfallback:ChatViewEmptyPlaceholderis the SDK's empty state, "No chat selected".ThreadSlotrenders the thread in thethreadslot, 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, layout.css loads the SDK's stylesheet into the stream layer, and the recipe's rules go in stream-overrides, after it, as Theming → CSS layers explains. The selector .str-chat__chat-view__channels matches only the channels view, so only the thread beside a channel gets the fixed width. The SDK has no variable for that width, so this one rule sets flex on the SDK's thread container. Workspace with ChatView explains views and slots in full.
How it works
Opening a thread beside the channel
The replies button under a message, and the reply actions on it, open its thread.
Example
The user clicks "3 replies" under Bob's message in Design review:
- The button calls
openThreadwith the channel and the message.openThreadgets the message's thread fromclient.threads, which creates it if it doesn't exist yet. - A thread opens in the view that's shown, here the channels view.
viewActionSlotResolverspicks its slot:thread. ThreadSlotrenders aThreadfor it, around the header, the message list and the composer. The thread loads its replies.ActiveThreadcallsuseActiveThread. 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.
ThreadHeaderrenders it on a thread beside a channel. It empties thethreadslot.ThreadSlotthen 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.
Related
- Workspace with ChatView: views, slots and how a channel or thread lands in one.
- Moving between channels, threads and messages: the ways to open and close a thread.
- Replies and threads: threads end to end.
- Read state and unread counts: when replies are marked read.
- Responsive layouts: the thread over the channel on small screens.
- Thread header: a thread header of your own.
- Single-pane mobile with back button: one panel at a time on a phone.