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

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's id. The duplicate check and the navigation's isChannelActive, closeChannel and the others compare it. A binding without a key is never a duplicate.
  • A channel binding opens in the channels view, switching to it first. Every other kind opens in the view that's shown.
  • A channelList or threadList binding makes its slot a list slot: navigation never picks it on its own, and close and release never 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, from layer: true, and from an open into a slot in use other than the main panel. While a slot has layers, Slot keeps 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 open replaces 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.
  • serializeEntityBinding is a SerializeLayoutEntityBinding: (binding: LayoutSlotBinding) => SerializedLayoutSlotBinding | undefined. It turns each binding into something JSON can hold, such as { key, payload: { cid } }. Return undefined to 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.
  • deserializeEntityBinding is a DeserializeLayoutEntityBinding: (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.