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

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 layouts with views. Without layouts, each view gets a single slot holding its list, and channels and threads have nowhere to open.
  • Keep layouts stable. Given a new array, ChatView starts its layout again, and everything open closes. Declare it outside the component, or memoize it. The same holds for duplicateEntityPolicy and resolveDuplicateEntity.

Where an opened channel or thread lands

Info:

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":

  1. "Design" opens in the channels view. No slot holds a channel yet, and channel is the first empty slot, so "Design" goes there.
  2. The thread opens in the view that's shown, the channels view. No slot holds a thread, and thread is the first empty slot, so the thread opens beside "Design".
  3. "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:

  1. the slot that viewActionSlotResolvers picks, if you passed it (see Changing navigation behaviour);
  2. the slot the call asks for, when that view has it;
  3. 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;
  4. the first empty slot;
  5. 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:

  • viewActionSlotResolvers picks the slot for a channel or a thread, per view. A resolver returns a slot name, or undefined to leave the choice to ChatView. It isn't asked for opens beside, with additive. 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" },
      };
  • duplicateEntityPolicy says 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. With allow, the default, both slots show it. With move, the other slot empties. With reject, nothing opens. resolveDuplicateEntity decides 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: true option;
  • pushLayer(slot, binding) from useChatViewNavigation(), 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

The props of ChatView and every component on this page are in ChatView components and Slot layout.