Slot layout
The slot layout is the model behind ChatView: its views, the slots in each view, and what each slot holds. This page lists its types, the layout controller, and the hooks that read and change it. Workspace with ChatView explains how it behaves.
Everything on this page imports from stream-chat-react/slot-layout. The navigation interface the SDK's components use, useWorkspaceNavigation(), imports from stream-chat-react: see Workspace navigation API.
The pieces
| Piece | What it does | Reference |
|---|---|---|
ChatView |
Creates the layout controller, provides the navigation, renders the shown view. | ChatView |
| The layout controller | Holds the layout's state and the methods that change it. Read it from useChatViewContext(). |
The layout controller |
useChatViewNavigation() |
Opens, stacks, hides and closes things in slots, following the navigation rules. | useChatViewNavigation |
Slot, WorkspaceLayout, ChannelSlot, ThreadSlot, ThreadListSlot |
Render what slots hold. | ChatView components |
WorkspaceNavigationAdapter |
Implements the WorkspaceNavigation interface over the slots. ChatView renders it. |
Workspace navigation API |
The layout controller knows which slots exist and what they hold. It doesn't know where they are on screen or how wide they are: the app's CSS places them, and Slot geometry reports what the CSS did.
Binding, not mounting
A slot holds a binding: the kind of thing it shows, a key, and the thing itself. Opening the channel messaging:design binds { kind: "channel", key: "messaging:design", source: channel } to a slot, and the component that renders the slot reads it from there. Where an opened channel or thread lands says which slot.
A binding you pass is a ChatViewEntityBinding: { key?: string; kind; source }. These are the kinds:
kind |
source |
Rendered by default by Slot |
|---|---|---|
channel |
Channel |
nothing: use ChannelSlot, or pass slotRenderers.channel |
thread |
Thread |
nothing: use ThreadSlot, or pass slotRenderers.thread |
channelList |
{} |
ChannelNavigation |
threadList |
{} |
ThreadList |
userProfile |
{ userId: string } |
the user's id |
memberList |
Channel |
nothing |
pinnedMessagesList |
Channel |
nothing |
searchResults |
{ query: string } |
nothing |
userList |
{ query: string } |
nothing |
- The key is what the layout recognizes the channel or thread by: the channel's
cid, or the thread'sid. The duplicate check and the navigation'sisChannelActive,closeChanneland the others compare it. A binding without a key is never a duplicate. - A
channelbinding opens in the channels view, switching to it first. Every other kind opens in the view that's shown. - A
channelListorthreadListbinding makes its slot a list slot: navigation never picks it on its own, andcloseandreleasenever empty it.
The layout controller stores bindings in another shape, LayoutSlotBinding: { key?: string; payload: unknown }, with the ChatViewEntityBinding as its payload. useLayoutViewState() returns that shape. Three functions convert:
| Function | Returns |
|---|---|
createChatViewSlotBinding(entity) |
The LayoutSlotBinding for a ChatViewEntityBinding. Its key is "<kind>:<key>". |
getChatViewEntityBinding(binding?) |
The ChatViewEntityBinding in a stored binding, or undefined. |
createThreadEntityBinding(client, { channel, message }) |
A thread binding for the thread of a parent message. It gets the thread from client.threads.ensure(). |
useChatViewNavigation
useChatViewNavigation() changes what slots hold, following the same rules as the SDK's components. Every member acts on the shown view, and does nothing for a slot the view doesn't have. Outside a ChatView, open returns a rejection and the other members do nothing.
| Member | Type | Description |
|---|---|---|
open |
(binding: ChatViewEntityBinding, options?: ChatViewOpenOptions) => OpenResult |
Opens the binding in a slot, as described in Where an opened channel or thread lands. An open rejected for want of a slot changes nothing. A channel rejected as a duplicate still closes the threads open beside it. |
openView |
(view: ChatView, options?: { slot?: string }) => void |
Shows a view. slot is ignored. |
pushLayer |
(slot: string, binding: ChatViewEntityBinding) => void |
Stacks the binding on top of what the slot shows. |
popLayer |
(slot: string) => void |
Removes the top layer. |
close |
(slot: string) => void |
Removes the top layer. When the slot has no layers, empties it, unless it's a list slot. |
release |
(slot: string) => void |
Empties the slot: what it holds, every layer on it, and its history. Does nothing for a list slot. |
hide |
(slot: string) => void |
Stops showing the slot. What it holds stays mounted. |
unhide |
(slot: string) => void |
Shows a hidden slot again. |
ChatViewOpenOptions:
| Option | Type | Default | Description |
|---|---|---|---|
slot |
string |
Opens in this slot, when the view has it. A viewActionSlotResolvers resolver is asked first, except with additive. |
|
additive |
boolean |
false |
Opens beside: skips the slot that already holds the same kind, and the resolvers. |
layer |
boolean |
false |
Stacks the binding on top of the target slot's content instead of replacing it. |
OpenResult says what happened:
status |
Other fields | When |
|---|---|---|
"opened" |
slot |
It went into an empty slot. Also returned, with nothing changed, when an open with additive finds it already shown in the slot beside. |
"replaced" |
slot, replaced |
It replaced what the slot held. replaced is the old LayoutSlotBinding. |
"layered" |
slot |
It was stacked on top of the slot's content. |
"rejected" |
reason: "no-available-slot" or "duplicate-binding" |
No slot can take it, because the view has none or only list slots, or the duplicate policy refused it. |
Where an opened channel or thread lands has an example of open with slot, and Layers one of pushLayer.
Layers and history
A slot can hold more than one thing over time: layers stacked on top of what it holds, and a back and forward history of what it held before. Layers and history explains both, and Hiding vs closing compares hide, close and release.
- Layers come from
pushLayer, fromlayer: true, and from an open into a slot in use other than the main panel. While a slot has layers,Slotkeeps what it holds and every layer mounted, and shows only the top one. Pushing the first layer and removing the last one mount what the slot holds again, so it loses its scroll position. Layers are never saved. - History grows when an
openreplaces what a slot holds: the old binding goes onto the slot's back list. Opening something new clears the slot's forward list, and emptying the slot clears both. The SDK's back button removes a layer; it doesn't use the history.
The layout controller
useChatViewContext().layoutController holds the layout's state. Its methods change the shown view only, and skip the checks useChatViewNavigation() makes: they also act on list slots. Use the controller from an effect that would otherwise depend on the navigation's functions, which change with every layout change.
| Member | Type | Description |
|---|---|---|
state |
StateStore<ChatViewLayoutState> |
The state: activeView, and under layouts, each view's ChatViewLayoutViewState. |
setActiveView |
(view: ChatView) => void |
Shows a view. |
openView |
(view: ChatView, options?: { slot?: string }) => void |
The same. slot is ignored. |
bind |
(slot: string, binding: LayoutSlotBinding) => void |
Puts a binding in a slot, replacing what it holds. Clears the slot's forward history, and doesn't add to its back history. |
openInLayout |
(binding: LayoutSlotBinding, options?: { targetSlot?: string }) => OpenResult |
Puts a binding in targetSlot, applying the duplicate policy, and adds the replaced binding to the slot's back history. Rejects without a targetSlot the view has. |
release |
(slot: string) => void |
Empties the slot: what it holds, its layers and its history. |
pushLayer |
(slot: string, binding: LayoutSlotBinding) => void |
Stacks a binding on top of the slot. |
popLayer |
(slot: string) => void |
Removes the top layer. |
goBack |
(slot: string) => void |
Shows the slot's previous binding. The current one goes onto its forward list. |
goForward |
(slot: string) => void |
Shows the next binding from the forward list. The current one goes onto the back list. |
hide |
(slot: string) => void |
Hides the slot. What it holds stays mounted. |
unhide |
(slot: string) => void |
Shows a hidden slot again. |
setAvailableSlots |
(slots: string[]) => void |
Replaces the slots of the shown view that navigation can open into. |
setSlotNames |
(slots?: string[]) => void |
Replaces the shown view's declared slots, the ones its LayoutDescriptor lists. |
The methods that take a binding take a LayoutSlotBinding: make one with createChatViewSlotBinding. Back and forward per slot and List slots have examples. The LayoutController class isn't exported, only its type.
Configuring ChatView
ChatView's props are listed on ChatView components. These are the types its layout props take.
Keep these props stable, declared outside the component or memoized. A new layouts, duplicateEntityPolicy or resolveDuplicateEntity starts the layout again, and everything open closes. A new slotRenderers or viewActionSlotResolvers makes every component that reads the layout or the navigation render again.
LayoutDescriptor
layouts takes one LayoutDescriptor per view:
| Field | Type | Description |
|---|---|---|
id |
"channels" | "threads" |
The view. |
slots |
string[] |
The view's slots, in order. The first slot that doesn't hold a list is the main panel. |
initialBindings |
Partial<Record<string, ChatViewEntityBinding>> |
What slots hold at the start, by slot name. Use it to put a channelList or threadList in a list slot. |
The built-in workspace layout has an example with initialBindings.
slotRenderers
slotRenderers is a ChatViewSlotRenderers: an object with a renderer per kind. A renderer receives ChatViewSlotRendererProps:
| Prop | Type | Description |
|---|---|---|
entity |
ChatViewEntityBinding |
The binding. |
source |
the kind's source |
The channel, thread or other thing. |
slot |
string |
The slot it renders in. |
A renderer you pass replaces the default for its kind only. It's called as a plain function, not rendered as a component, so it must not call hooks: return an element, and call the hooks in that element's component. A renderer that returns null, undefined or a boolean leaves the slot empty. Slot and slotRenderers has an example.
SlotFallback and slotFallbackComponents take components that receive ChatViewSlotFallbackProps, { slot: string }.
Duplicates
duplicateEntityPolicy and resolveDuplicateEntity decide what happens when a channel or thread is opened into a slot while a slot of the view already holds it:
DuplicateEntityPolicy |
What happens |
|---|---|
"allow" |
Both slots show it. The default. |
"move" |
The other slot is emptied. |
"reject" |
Nothing opens. open returns "duplicate-binding". |
resolveDuplicateEntity is a ResolveDuplicateEntity: it decides per open, and wins over duplicateEntityPolicy. It receives ResolveDuplicateEntityArgs:
| Field | Type | Description |
|---|---|---|
binding |
LayoutSlotBinding |
The binding being opened. |
existingSlot |
string |
The slot that already holds it. |
requestedSlot |
string | undefined |
The slot it's being opened into. |
activeViewState |
ChatViewLayoutViewState |
The shown view's slots. |
state |
ChatViewLayoutState |
The whole layout state. |
This one moves a channel that's opened again, and lets a thread show in two slots:
import { getChatViewEntityBinding } from "stream-chat-react/slot-layout";
import type { ResolveDuplicateEntity } from "stream-chat-react/slot-layout";
export const resolveDuplicateEntity: ResolveDuplicateEntity = ({ binding }) =>
getChatViewEntityBinding(binding)?.kind === "channel" ? "move" : "allow";A channel or thread stacked as a layer isn't checked.
viewActionSlotResolvers
viewActionSlotResolvers takes, per view, a ViewActionSlotResolvers: { openChannel?, openThread? }. Each is a ResolveViewActionTargetSlot, which returns a slot name, or undefined to leave the choice to ChatView. It receives ResolveViewActionTargetSlotArgs:
| Field | Type | Description |
|---|---|---|
action |
"openChannel" | "openThread" |
What's being opened. |
activeView |
ChatView |
The view it opens in. |
availableSlots |
string[] |
The view's slots that can be opened into. |
slotNames |
string[] | undefined |
The view's declared slots, or availableSlots when it declares none. |
slotBindings |
Record<string, LayoutSlotBinding | undefined> |
What each slot holds. |
requestedSlot |
string | undefined |
The slot the open asked for. |
A resolver isn't asked for an open with additive, and a slot name the view doesn't have is ignored. Changing navigation behaviour has an example.
deriveWorkspaceNavigation
deriveWorkspaceNavigation is a DeriveWorkspaceNavigation. See Overriding navigation.
Reading layout state
These hooks read the layout and re-render when it changes. They throw outside a ChatView. Without view, they read the shown view.
| Hook | Returns |
|---|---|
useLayoutViewState(view?) |
The view's ChatViewLayoutViewState. |
useSlotEntity({ kind, slot?, view? }) |
The source of the first slot that holds kind, or undefined. With slot, only that slot is checked. |
useSlotEntities({ kind, slot?, view? }) |
Every slot that holds kind, as { slot, source }. |
useSlotTopLayerEntity({ kind, slot?, view? }) |
The source of the first slot whose top layer is of kind, or undefined. |
useSlotChannel({ slot?, view? }) |
useSlotEntity for channel. The options are optional. |
useSlotChannels({ slot?, view? }) |
Every slot that holds a channel, as { channel, slot }. |
useSlotThread({ slot?, view? }) |
useSlotEntity for thread. |
useSlotThreads({ slot?, view? }) |
Every slot that holds a thread, as { slot, thread }. |
useSlotForKind(kind) |
The first slot of the shown view that holds kind. |
useSlotForKey(key) |
The slot of the shown view that holds the channel or thread with key: a cid or a thread id. |
The entity hooks read what a slot holds underneath any layer, except useSlotTopLayerEntity. Pass slot to be precise: without it, they return the first match, which is only right in a layout with one of each.
import { useSlotEntity, useSlotThreads } from "stream-chat-react/slot-layout";
export const WorkspaceStatus = () => {
const channel = useSlotEntity({ kind: "channel", slot: "channel" });
const threads = useSlotThreads();
return (
<p>
{channel ? channel.cid : "No channel"}, {threads.length} threads open
</p>
);
};getLayoutViewState(state, view?) reads the same without a hook, for example from layoutController.state.getLatestValue().
ChatViewLayoutViewState
| Field | Type | Description |
|---|---|---|
availableSlots |
string[] |
The slots navigation can open into, in order. |
slotNames |
string[] | undefined |
The view's declared slots. |
slotBindings |
Record<string, LayoutSlotBinding | undefined> |
What each slot holds. |
slotLayers |
Record<string, LayoutSlotBinding[] | undefined> |
The layers on each slot, bottom first. Optional in the type. |
hiddenSlots |
Record<string, boolean | undefined> |
Which slots are hidden. |
slotHistory |
Record<string, LayoutSlotBinding[] | undefined> |
Each slot's back list, oldest first. |
slotForwardHistory |
Record<string, LayoutSlotBinding[] | undefined> |
Each slot's forward list. |
slotMeta |
Record<string, { occupiedAt?: number } | undefined> |
When each slot was filled, as a timestamp in milliseconds. |
Saving and restoring
| Function | Returns |
|---|---|
serializeLayoutState(state, { serializeEntityBinding? }) |
A ChatViewLayoutSnapshot: the shown view, and each view's slots, what they hold, which are hidden, and their history. |
restoreLayoutState(snapshot, { deserializeEntityBinding? }) |
The ChatViewLayoutState to set on the layout controller's state. |
serializeLayoutControllerState(layoutController, options?) |
serializeLayoutState of the controller's current state. |
restoreLayoutControllerState(layoutController, snapshot, options?) |
Nothing: sets the restored state on the controller. |
serializeEntityBindingis aSerializeLayoutEntityBinding:(binding: LayoutSlotBinding) => SerializedLayoutSlotBinding | undefined. It turns each binding into something JSON can hold, such as{ key, payload: { cid } }. Returnundefinedto leave a binding out. Without it, bindings are copied as they are, channel and thread objects included, so the snapshot can't be stored as JSON.deserializeEntityBindingis aDeserializeLayoutEntityBinding:(saved: SerializedLayoutSlotBinding) => LayoutSlotBinding | undefined. It turns each saved entry back into a binding. Without it, saved entries are used as they are.- Layers aren't saved.
Saving and restoring a layout has a complete example.