Workspace with ChatView
ChatView, from stream-chat-react/slot-layout, is the SDK's layout. It decides where an opened channel or thread goes, remembers what each panel shows, and implements Navigation for the components inside it.
This page builds on the app from Add threads in the Quick start. Its layout part is:
import {
ChannelHeader,
ChannelNavigation,
MessageComposer,
MessageList,
ThreadHeader,
ThreadList,
} from "stream-chat-react";
import {
ChannelSlot,
ChatView,
ThreadSlot,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
// Each view's slots, in order. Declared outside the component, so ChatView gets
// the same array on every render.
const layouts: LayoutDescriptor[] = [
{ id: "channels", slots: ["channel", "thread"] },
{ id: "threads", slots: ["thread"] },
];
const ThreadPanel = () => (
<ThreadSlot slot="thread">
<ThreadHeader />
<MessageList />
<MessageComposer />
</ThreadSlot>
);
const ChannelsView = () => (
<>
<ChatView.Selector />
<ChannelNavigation />
<ChannelSlot slot="channel">
<ChannelHeader />
<MessageList />
<MessageComposer />
</ChannelSlot>
<ThreadPanel />
</>
);
const ThreadsView = () => (
<>
<ChatView.Selector />
<ThreadList />
<ThreadPanel />
</>
);
export const Workspace = () => (
<ChatView
layouts={layouts}
views={{ channels: <ChannelsView />, threads: <ThreadsView /> }}
/>
);Views and slots
A view is one screen of the workspace. ChatView has two: channels and threads. One is shown at a time, and ChatView.Selector switches between them.
A slot is a named place in a view. It holds one thing at a time: a channel, a thread or a list. layouts declares each view's slots, in order, and the first slot that doesn't hold a list is the view's main panel. Here, the channels view has a channel slot and a thread slot, and the threads view has its own thread slot.
views gives each view the content to render. The content puts the slots where it wants them: ChannelSlot renders the channel in the channel slot, and ThreadSlot the thread in a thread slot. Each view keeps what its slots hold while the other one is shown.
ChatView's children render in every view, next to the shown view's content. Components that must always run, such as DisposedChannelRelease, go there.
Two things to keep in mind:
- Declare
layoutswithviews. Withoutlayouts, each view gets a single slot holding its list, and channels and threads have nowhere to open. - Keep
layoutsstable. Given a new array,ChatViewstarts its layout again, and everything open closes. Declare it outside the component, or memoize it. The same holds forduplicateEntityPolicyandresolveDuplicateEntity.
Where an opened channel or thread lands
Example
The user uses the app above. They click "Design" in the channel list, open the replies to one of its messages, then click "Support":
- "Design" opens in the channels view. No slot holds a channel yet, and
channelis the first empty slot, so "Design" goes there. - The thread opens in the view that's shown, the channels view. No slot holds a thread, and
threadis the first empty slot, so the thread opens beside "Design". - "Support" opens where "Design" is, because that slot holds a channel. "Design" is replaced, and the thread beside it closes.
ChatView follows the same steps for every open.
First, the view. A channel opens in the channels view, switching to it if the threads view is shown. A thread opens in the view that's shown.
Then, the slot. The first of these that applies:
- the slot that
viewActionSlotResolverspicks, if you passed it (see Changing navigation behaviour); - the slot the call asks for, when that view has it;
- the slot that already holds the same kind of thing: a channel for a channel, a thread for a thread. Opening beside, with
additive, skips this step; - the first empty slot;
- the last slot that doesn't hold a list.
When none applies, because every slot holds a list, nothing opens and nothing changes.
Last, replace or stack. In the main panel, the first slot that doesn't hold a list, the new content replaces what's there. In any other slot in use, it's stacked on top as a layer, unless the slot holds the same kind of thing: a thread replaces a thread. A channel opened beside another channel is stacked too, so going back returns to the first one. layer: true always stacks.
When a channel opens without being stacked, the threads open beside it close, even when it's the channel that was already open. Something opened on top of such a thread stays, in the thread's place.
Only useChatViewNavigation() can ask for a slot. Its open takes the channel or thread with its kind, and returns whether it opened, replaced something, was layered, or was rejected:
import type { Channel as StreamChannel } from "stream-chat";
import { useChatViewNavigation } from "stream-chat-react/slot-layout";
export const OpenOnTheRightButton = ({
channel,
}: {
channel: StreamChannel;
}) => {
const { open } = useChatViewNavigation();
return (
<button
onClick={() =>
open(
{ key: channel.cid, kind: "channel", source: channel },
{ slot: "thread" },
)
}
type="button"
>
Open on the right
</button>
);
};The key is what ChatView recognizes the channel or thread by: the channel's cid, or the thread's id.
Rendering a slot
A slot holds a channel or a thread; something has to render it. There are three ways.
ChannelSlot and ThreadSlot
ChannelSlot renders a Channel for the channel in its slot, around its children. ThreadSlot does the same with a Thread. While the slot is empty, each renders its fallback, or nothing. This is the way the Quick start uses.
Always pass slot. Without it, they render the first channel or thread in the view, which is only right in a layout with one of each.
ThreadSlot doesn't tell the thread it's on screen. useActiveThread does: while the window is visible and focused, new replies are marked read, and a thread that went out of date, after a lost connection for example, reloads. Call it from a component inside the slot:
import {
ChannelHeader,
MessageComposer,
MessageList,
ThreadHeader,
useThreadContext,
} from "stream-chat-react";
import {
ChannelSlot,
ChatViewEmptyPlaceholder,
ThreadSlot,
useActiveThread,
} from "stream-chat-react/slot-layout";
const ActiveThread = () => {
const thread = useThreadContext();
useActiveThread({ activeThread: thread });
return null;
};
export const ChannelPanel = () => (
<ChannelSlot fallback={<ChatViewEmptyPlaceholder />} slot="channel">
<ChannelHeader />
<MessageList />
<MessageComposer />
</ChannelSlot>
);
export const ActiveThreadPanel = () => (
<ThreadSlot slot="thread">
<ActiveThread />
<ThreadHeader />
<MessageList />
<MessageComposer />
</ThreadSlot>
);ChatViewEmptyPlaceholder is the SDK's empty state: "No chat selected", or "No thread selected" in the threads view.
ChannelSlot and ThreadSlot show what the slot holds underneath any layer. To show a layer too, use Slot, or read the layer with useSlotTopLayerEntity({ kind, slot }) and render it over the slot.
Slot and slotRenderers
Slot renders whatever its slot holds, and its layers. ChatView's slotRenderers say how to render each kind of thing. This view has two slots side by side, and each can show a channel or a thread:
import type { Thread as StreamThread } from "stream-chat";
import {
Channel,
ChannelHeader,
ChannelNavigation,
MessageComposer,
MessageList,
Thread,
ThreadHeader,
} from "stream-chat-react";
import { ChatView, Slot, useActiveThread } from "stream-chat-react/slot-layout";
import type {
ChatViewSlotRenderers,
LayoutDescriptor,
} from "stream-chat-react/slot-layout";
const sideBySideLayouts: LayoutDescriptor[] = [
{ id: "channels", slots: ["left", "right"] },
];
const ThreadContent = ({ thread }: { thread: StreamThread }) => {
useActiveThread({ activeThread: thread });
return (
<Thread thread={thread}>
<ThreadHeader />
<MessageList />
<MessageComposer />
</Thread>
);
};
// Each renderer returns an element. Hooks go in the component it renders.
const slotRenderers: ChatViewSlotRenderers = {
channel: ({ source }) => (
<Channel channel={source}>
<ChannelHeader />
<MessageList />
<MessageComposer />
</Channel>
),
thread: ({ source }) => <ThreadContent thread={source} />,
};
const SideBySideView = () => (
<>
<ChannelNavigation />
<Slot slot="left" />
<Slot slot="right" />
</>
);
export const SideBySideWorkspace = () => (
<ChatView
layouts={sideBySideLayouts}
slotRenderers={slotRenderers}
views={{ channels: <SideBySideView /> }}
/>
);A renderer is called as a plain function, not rendered as a component. Calling a hook in it breaks when the slot switches from one kind to another, so it returns an element, here ThreadContent, and the hooks go there. Declare slotRenderers outside the component too.
Slot also takes children, which it renders instead of what the slot holds.
The built-in workspace layout
With layout="nav-rail-entity-list-workspace" and no views, ChatView renders the view itself: the view selector in a rail, then every slot of the shown view, through slotRenderers. A slot can hold a list from the start, through initialBindings. The SDK renders a channelList as ChannelNavigation and a threadList as ThreadList:
import {
ChatView,
ChatViewEmptyPlaceholder,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
// The slotRenderers from the previous example, in a module of their own.
import { slotRenderers } from "./slotRenderers";
const workspaceLayouts: LayoutDescriptor[] = [
{
id: "channels",
initialBindings: { list: { kind: "channelList", source: {} } },
slots: ["list", "channel", "thread"],
},
{
id: "threads",
initialBindings: { list: { kind: "threadList", source: {} } },
slots: ["list", "thread"],
},
];
const slotFallbackComponents = { channel: ChatViewEmptyPlaceholder };
export const BuiltInWorkspace = () => (
<ChatView
layout="nav-rail-entity-list-workspace"
layouts={workspaceLayouts}
slotFallbackComponents={slotFallbackComponents}
slotRenderers={slotRenderers}
/>
);An empty slot renders its component from slotFallbackComponents, or SlotFallback for every empty slot. Without either, an empty slot stays blank, and while every slot is blank, the layout shows ChatViewEmptyPlaceholder. These two props apply only to the built-in layout.
The SDK's stylesheet gives every slot an equal share of the width, and a slot with nothing in it gives up its share. Give the list slot a fixed width:
.str-chat__chat-view__slot[data-slot="list"] {
flex: 0 0 320px;
}In this layout, ChatView doesn't render its children.
Changing navigation behaviour
deriveWorkspaceNavigation changes what the navigation of the components inside ChatView does. It receives the SDK's navigation and returns the members to replace. The members it leaves out keep the SDK's behaviour. This one opens a channel beside the open one on ⌘/ctrl-click:
import type { DeriveWorkspaceNavigation } from "stream-chat-react/slot-layout";
// Declared outside the component, so ChatView gets the same function on every render.
export const deriveWorkspaceNavigation: DeriveWorkspaceNavigation = (base) => ({
openChannel: (channel, options) =>
base.openChannel(channel, {
...options,
additive:
options?.additive ??
!!(options?.event?.metaKey || options?.event?.ctrlKey),
}),
});Pass it as <ChatView deriveWorkspaceNavigation={deriveWorkspaceNavigation}>. Keep it stable: declare it outside the component, or wrap it in useCallback. A new function on every render rebuilds the navigation on every render. ⌘/ctrl-click opens beside walks through this example.
The override reaches every component that uses useWorkspaceNavigation(), selecting a pinned message and Send direct message in channel details included. Those two pass no event, so this override opens their channel in place of the open one.
Two more props change where things go:
-
viewActionSlotResolverspicks the slot for a channel or a thread, per view. A resolver returns a slot name, orundefinedto leave the choice toChatView. It isn't asked for opens beside, withadditive. Keep it stable too:import type { ChatViewProps } from "stream-chat-react/slot-layout"; // In the channels view, threads always open in the "thread" slot. export const viewActionSlotResolvers: ChatViewProps["viewActionSlotResolvers"] = { channels: { openThread: () => "thread" }, }; -
duplicateEntityPolicysays what happens when a channel or thread is opened into a slot while another slot holds it, for example when the user opens the open channel beside itself. Withallow, the default, both slots show it. Withmove, the other slot empties. Withreject, nothing opens.resolveDuplicateEntitydecides per open instead, and returns one of the three. A channel or thread stacked as a layer isn't checked.
Layers and history
A slot can hold more than one thing over time, in two ways: layers on top of each other, and a history of what it held before.
Layers
A layer is something opened on top of a slot's content. The slot's own content mounts again when the first layer opens and when the last one closes, so it loses its scroll position and the state kept in its components. From the second layer up, everything beneath the top layer stays mounted.
For example, take the side-by-side app from Slot and slotRenderers, with the ⌘/ctrl-click override from Changing navigation behaviour. "Design" is open on the left, and a thread from it on the right. The user ⌘-clicks "Support". It opens on the right, on top of the thread. "Support"'s header now has a back button. Back removes "Support", and the thread shows again. "Support" was the only layer, so the thread mounts again and doesn't keep its scroll position. Close closes the whole right panel: "Support" and the thread beneath it.
Layers come from three places:
- an open into a slot in use other than the main panel, as described in Where an opened channel or thread lands;
- the
layer: trueoption; pushLayer(slot, binding)fromuseChatViewNavigation(), which stacks anything on a slot.
This hook opens a member's profile on top of the thread panel:
import { useChatViewNavigation } from "stream-chat-react/slot-layout";
export const useOpenProfile = () => {
const { pushLayer } = useChatViewNavigation();
return (userId: string) =>
pushLayer("thread", {
key: userId,
kind: "userProfile",
source: { userId },
});
};The SDK's renderer for userProfile shows only the user's id. Render your own through slotRenderers.userProfile.
close(slot) and popLayer(slot) remove the top layer. Only Slot renders layers; see Rendering a slot.
Back and forward per slot
Each slot remembers what it held before. When an open replaces what a slot holds, the old content goes onto that slot's back list. Going back shows it again and keeps the current content for going forward. Opening something new clears the forward list, and emptying the slot clears both.
The SDK's back button doesn't use this history: it removes a layer. For buttons of your own, call LayoutController.goBack(slot) and LayoutController.goForward(slot) on ChatView's layout controller:
import {
useChatViewContext,
useLayoutViewState,
} from "stream-chat-react/slot-layout";
export const ChannelHistoryButtons = () => {
const { layoutController } = useChatViewContext();
const { slotForwardHistory, slotHistory } = useLayoutViewState();
return (
<>
<button
disabled={!slotHistory.channel?.length}
onClick={() => layoutController.goBack("channel")}
type="button"
>
Previous channel
</button>
<button
disabled={!slotForwardHistory.channel?.length}
onClick={() => layoutController.goForward("channel")}
type="button"
>
Next channel
</button>
</>
);
};useLayoutViewState() reads the shown view's slots, what they hold, their layers and their history.
Hiding vs closing
useChatViewNavigation() has three ways to take something off screen:
| Call | What it does |
|---|---|
hide(slot) |
Stops showing the slot. What it holds stays mounted. unhide(slot) shows it again. |
close(slot) |
Removes the top layer. When the slot has no layers, it empties the slot. |
release(slot) |
Empties the slot: what it holds, every layer on it, and its history. |
A hidden Slot gets the str-chat__chat-view__slot--hidden class, which hides it, and aria-hidden. ChannelSlot and ThreadSlot don't read the hidden flag. ThreadSlot hides its slot while it holds no thread, unless you pass hideIfEmpty={false}; that matters when a Slot is around it.
The close button in a header uses close in the main panel and release in the panels beside it. The back button uses close.
List slots
A slot that holds a channelList or a threadList is a list slot. Navigation never picks it on its own for what it opens, and close and release never empty it. To take a list off screen, hide its slot.
For example, a one-column layout built on the built-in workspace layout can hide the list while a channel is open:
import { useEffect } from "react";
import {
useChatViewContext,
useSlotChannel,
} from "stream-chat-react/slot-layout";
export const HideListWhileChannelOpen = () => {
const { layoutController } = useChatViewContext();
const channel = useSlotChannel({ slot: "channel" });
useEffect(() => {
if (channel) layoutController.hide("list");
else layoutController.unhide("list");
}, [channel, layoutController]);
return null;
};It calls the layout controller rather than useChatViewNavigation(). The navigation's functions change with every layout change, so an effect that depends on them would run again after every change it makes.
Saving and restoring a layout
A layout can be saved, for example to localStorage, and restored when the app loads.
Saving
serializeLayoutState(state, options) turns the layout's state into a snapshot: the shown view, and for each view its slots, what they hold, which are hidden, and their history.
What a slot holds is the channel or thread object itself, which can't be stored as JSON. Pass serializeEntityBinding to turn each one into something that can, such as the channel's cid. Return undefined to leave one out.
Restoring
restoreLayoutState(snapshot, options) turns a snapshot back into the layout's state. Its deserializeEntityBinding turns each saved entry back into a channel or thread. Set the result on the layout controller.
This component saves the open channels on every change, and restores them when it mounts. Render it inside ChatView:
import { useEffect } from "react";
import type { StreamChat } from "stream-chat";
import { useChatContext } from "stream-chat-react";
import {
createChatViewSlotBinding,
getChatViewEntityBinding,
restoreLayoutState,
serializeLayoutState,
useChatViewContext,
} from "stream-chat-react/slot-layout";
import type {
ChatViewLayoutSnapshot,
DeserializeLayoutEntityBinding,
SerializeLayoutEntityBinding,
} from "stream-chat-react/slot-layout";
const STORAGE_KEY = "chat-layout";
// A channel is saved as its cid. Everything else, such as threads, is left out.
const serializeEntityBinding: SerializeLayoutEntityBinding = (binding) => {
const entity = getChatViewEntityBinding(binding);
if (entity?.kind !== "channel") return undefined;
return { key: binding.key, payload: { cid: entity.source.cid } };
};
const createDeserializer =
(client: StreamChat): DeserializeLayoutEntityBinding =>
(saved) => {
const { cid } = saved.payload as { cid: string };
const [type, id] = cid.split(":");
const channel = client.channel(type, id);
// A restored channel isn't loaded yet; watching loads it.
channel
.watch()
.catch((error) => console.error("Could not watch the channel", error));
return createChatViewSlotBinding({
key: cid,
kind: "channel",
source: channel,
});
};
export const LayoutPersistence = () => {
const { client } = useChatContext();
const { layoutController } = useChatViewContext();
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const snapshot = JSON.parse(saved) as ChatViewLayoutSnapshot;
layoutController.state.next(
restoreLayoutState(snapshot, {
deserializeEntityBinding: createDeserializer(client),
}),
);
}
return layoutController.state.subscribe((state) => {
const snapshot = serializeLayoutState(state, { serializeEntityBinding });
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
});
}, [client, layoutController]);
return null;
};serializeLayoutControllerState(layoutController, options) and restoreLayoutControllerState(layoutController, snapshot, options) do the same in one call each.
restoreLayoutState calls the deserializer for every saved entry, each slot's back and forward history included, so this one watches every saved channel, not only the ones on screen. Watching is get-or-create: a saved channel that has since been deleted is created again. Saving and restoring the layout watches each channel when it's shown instead, and also keeps the open channel's cid in the URL.
What isn't saved
- Layers. A reload restores what each slot holds underneath, never something opened on top of it.
- What your serializer leaves out. Above, threads aren't saved, so a restored channel opens without its thread. In a layout with list slots, save the lists too, or a restored list slot is empty. A list binding holds no data, so it can be saved as it is.
- Loaded data. A snapshot names channels; it doesn't hold their messages. Each restored channel loads again.
Disposed channels
When a channel is deleted, or the user is removed from it, stream-chat disposes of it: it no longer receives events. A slot that keeps showing it shows a channel that's gone.
DisposedChannelRelease empties every slot that shows a disposed channel, so the slot shows its empty state instead. It's opt-in: render it once inside ChatView, with the views:
export const WorkspaceWithCleanup = () => (
<ChatView
layouts={layouts}
views={{ channels: <ChannelsView />, threads: <ThreadsView /> }}
>
<DisposedChannelRelease />
</ChatView>
);A hidden channel isn't disposed of, so it stays in its slot. When a channel is replaced by another instance of it, ChatView moves the slot to the new instance itself. Entity lifecycle explains both.
Used in
- Quick start
- Channel and thread side by side
- ⌘/ctrl-click opens beside
- Single-pane mobile with back button
- Saving and restoring the layout
The props of ChatView and every component on this page are in ChatView components and Slot layout.