# Workspace with ChatView

> **Info:** This is release candidate documentation for **Stream Chat React SDK v15**. For the latest stable version, see the [latest version (v14)](https://getstream.io/chat/docs/sdk/react/).

`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](https://getstream.io/chat/docs/sdk/react/v15/ui/layout/navigation/) for the components inside it.

This page builds on the app from [Add threads](https://getstream.io/chat/docs/sdk/react/v15/getting-started/quick-start/#add-threads) in the Quick start. Its layout part is:

```tsx
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`](#disposed-channels), 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](#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](#layers), 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`:

```tsx
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:

```tsx
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](#layers). 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:

```tsx
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`:

```tsx
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:

```css
.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:

```tsx
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](https://getstream.io/chat/docs/sdk/react/v15/recipes/layout/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:

  ```tsx
  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`](#slot-and-slotrenderers), with the ⌘/ctrl-click override from [Changing navigation behaviour](#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](#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:

```tsx
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](#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:

```tsx
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](#the-built-in-workspace-layout) can hide the list while a channel is open:

```tsx
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`:

```tsx
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](https://getstream.io/chat/docs/sdk/react/v15/recipes/layout/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:

```tsx
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](https://getstream.io/chat/docs/sdk/react/v15/state/state-and-entities/entity-lifecycle/) explains both.

## Used in

- [Quick start](https://getstream.io/chat/docs/sdk/react/v15/getting-started/quick-start/)
- [Channel and thread side by side](https://getstream.io/chat/docs/sdk/react/v15/recipes/layout/channel-and-thread-side-by-side/)
- [⌘/ctrl-click opens beside](https://getstream.io/chat/docs/sdk/react/v15/recipes/layout/ctrl-click-opens-beside/)
- [Single-pane mobile with back button](https://getstream.io/chat/docs/sdk/react/v15/recipes/layout/single-pane-mobile-with-back-button/)
- [Saving and restoring the layout](https://getstream.io/chat/docs/sdk/react/v15/recipes/layout/saving-and-restoring-the-layout/)

The props of `ChatView` and every component on this page are in [ChatView components](https://getstream.io/chat/docs/sdk/react/v15/component-reference/layout/chatview-components/) and [Slot layout](https://getstream.io/chat/docs/sdk/react/v15/component-reference/layout/slot-layout/).

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/v15/ui/layout/workspace-with-chatview/](https://getstream.io/chat/docs/sdk/react/v15/ui/layout/workspace-with-chatview/).