Navigation

The SDK's components don't decide where a channel or a thread opens. A row in the channel list calls openChannel(channel) and leaves the rest to the layout, and every component that opens, closes or highlights a channel or a thread does the same, through one interface: useWorkspaceNavigation(). Use this page to call that interface from your own components, to know what happens without a layout, and to provide a navigation of your own. Workspace with ChatView explains the SDK's layout, and Moving between channels, threads and messages lists every component that uses the interface. The members and options are in Workspace navigation API.

One navigation interface

useWorkspaceNavigation(), from stream-chat-react, returns the same object anywhere below the layout:

Member What it does
openChannel(channel, options?) Opens a channel
openThread(target, options?) Opens a thread. The target is a Thread, or { channel, message } for the thread of that parent message.
closeChannel(cid?, options?), closeThread(threadId?, options?) Close the panel that shows the channel or the thread
goBack(key?, options?), canGoBack(key?, panel?) Remove a channel or thread opened on top of something else, and say whether there's anything beneath it
openChannels, openThreads What the panels hold, without anything opened on top of them
isChannelActive(cid?), isThreadActive(threadId?) Whether a channel or thread is on screen: open in a panel and not covered by something opened on top of it
isChannelDismissable, isThreadDismissable Whether the panel that shows it can be closed
isThreadsView Whether the thread list's view is the one shown

Here is a SupportButton component that opens a support channel, and shows as pressed while that channel is on screen:

import type { Channel as StreamChannel } from "stream-chat";
import { useWorkspaceNavigation } from "stream-chat-react";

export const SupportButton = ({ channel }: { channel: StreamChannel }) => {
  const { isChannelActive, openChannel } = useWorkspaceNavigation();

  return (
    <button
      aria-pressed={isChannelActive(channel.cid)}
      onClick={(event) => openChannel(channel, { event })}
      type="button"
    >
      Support
    </button>
  );
};

openChannel places a channel; it doesn't load it. A channel from a channel list is loaded by the list's query. A channel you get from client.channel() needs Channel.watch() first, as in Show one channel.

Beside and on top

Every operation takes the same options:

Option What it does
additive true opens beside the current content instead of replacing it. In a layout with a channel slot and a thread slot, a channel opened this way goes into the thread slot, next to the open channel. A layout with one slot ignores it.
layer true opens on top of what the panel shows, as a layer. Going back removes it and shows what's beneath.
event The click or key press that started the navigation. The channel list, search results and header buttons pass it, so an override can read the ⌘ or ctrl key. The SDK doesn't act on it itself.
panel The panel the call comes from. See Panels.

The two can happen together. When the slot beside the channel already shows a thread, <ChatView/> stacks a channel opened beside it on top of that thread. The back button in that channel's header returns to the thread. Where an opened channel or thread lands has the full rules.

What shows depends on how the layout renders the slot. <Slot/> shows whatever the slot holds, layers included. <ChannelSlot/> shows only a channel and <ThreadSlot/> only a thread, without layers. So in the Quick start app, a channel opened beside or on top doesn't show. See Rendering a slot.

The thread list opens a thread beside the current one on ⌘/ctrl-click, when its view has a second slot for it. The channel list passes the event but always replaces the open channel; ⌘/ctrl-click opens beside changes that.

Panels

A panel is the part of the screen that shows a slot. The same channel can be open in two panels at once, for example after the user opens it beside itself. closeChannel(cid) then has to know which of the two to close.

<ChannelSlot/>, <ThreadSlot/> and <Slot/> declare the panel they render through <WorkspacePanelProvider/>, and useWorkspacePanel() reads it. The back and close buttons in <ChannelHeader/> and <ThreadHeader/> pass it as panel, so each acts on its own panel. Here is a CloseChannelButton component that does the same:

import {
  useChannel,
  useWorkspaceNavigation,
  useWorkspacePanel,
} from "stream-chat-react";

export const CloseChannelButton = () => {
  const channel = useChannel();
  const panel = useWorkspacePanel();
  const { closeChannel, isChannelDismissable } = useWorkspaceNavigation();

  // Only a channel opened beside another one can be closed.
  if (!isChannelDismissable(channel.cid, panel)) return null;

  return (
    <button
      onClick={(event) => closeChannel(channel.cid, { event, panel })}
      type="button"
    >
      Close
    </button>
  );
};

The SDK's own buttons are <WorkspaceNavigationBackButton/> and WorkspaceNavigationCloseButton. A custom header can render them to keep them.

Without a layout

Outside <ChatView/> and any <WorkspaceNavigationProvider/>, useWorkspaceNavigation() returns a default that does nothing. Every open and close is ignored, openChannels and openThreads are empty, and every check returns false. Components call it without checking for a layout, so they still render:

  • A row in the channel list does nothing when clicked, unless it has an onSelect handler.
  • The replies button and the thread actions in the message menu do nothing.
  • No row in the channel list is highlighted as open.
  • The back and close buttons in the headers render nothing.
  • Selecting a pinned message in channel details still jumps to the message in the channel, and Send direct message on a member's profile still creates the direct message channel, but neither opens the channel.

The first time each of openChannel, openThread, closeChannel, closeThread and goBack is called on the default, it logs a console warning that no workspace navigation is provided. The defaultWorkspaceNavigation export doesn't warn, so the members you leave out when you spread it into your own navigation stay silent.

An app that shows one channel needs none of this: it passes the channel to <Channel/> itself, as in the Quick start. For navigation to do something, render <ChatView/>, or provide your own.

Providing your own navigation

<WorkspaceNavigationProvider/> gives the components below it a navigation of your own. Here is a TwoPaneChat component that keeps the open channel and thread in React state, without <ChatView/>:

import { useMemo, useState } from "react";
import { Thread as StreamThread } from "stream-chat";
import type { Channel as StreamChannel } from "stream-chat";
import {
  Channel,
  ChannelHeader,
  ChannelNavigation,
  defaultWorkspaceNavigation,
  MessageComposer,
  MessageList,
  Thread,
  ThreadHeader,
  useChatContext,
  WorkspaceNavigationProvider,
} from "stream-chat-react";
import type { WorkspaceNavigation } from "stream-chat-react";
import { useActiveThread } from "stream-chat-react/slot-layout";

export const TwoPaneChat = () => {
  const { client } = useChatContext();
  const [channel, setChannel] = useState<StreamChannel>();
  const [thread, setThread] = useState<StreamThread>();

  // Marks the thread as on screen, so its new replies are marked read.
  useActiveThread({ activeThread: thread });

  const navigation = useMemo<WorkspaceNavigation>(
    () => ({
      // The members left out keep the defaults, which do nothing.
      ...defaultWorkspaceNavigation,
      closeThread: () => setThread(undefined),
      isChannelActive: (cid) => cid !== undefined && cid === channel?.cid,
      isThreadActive: (threadId) =>
        threadId !== undefined && threadId === thread?.id,
      isThreadDismissable: () => true,
      openChannel: (nextChannel) => {
        setChannel(nextChannel);
        setThread(undefined);
      },
      openChannels: channel ? [channel] : [],
      openThread: (target) =>
        setThread(
          target instanceof StreamThread
            ? target
            : client.threads.ensure({
                channel: target.channel,
                parentMessage: target.message,
              }),
        ),
      openThreads: thread ? [thread] : [],
    }),
    [channel, client, thread],
  );

  return (
    <WorkspaceNavigationProvider value={navigation}>
      <ChannelNavigation />
      {channel && (
        <Channel channel={channel}>
          <ChannelHeader />
          <MessageList />
          <MessageComposer />
        </Channel>
      )}
      {thread && (
        <Thread thread={thread}>
          <ThreadHeader />
          <MessageList />
          <MessageComposer />
        </Thread>
      )}
    </WorkspaceNavigationProvider>
  );
};

Here is what each part does:

  1. A click on a row in the channel list calls openChannel, which shows the channel and closes the thread.
  2. The replies button calls openThread with the channel and the parent message. client.threads.ensure() returns the thread for that message, creating it if it doesn't exist yet.
  3. isThreadDismissable returns true, so <ThreadHeader/> shows its close button, which calls closeThread.
  4. isChannelActive highlights the open channel's row.

Memoize the value: every component that reads it re-renders when it changes. useActiveThread comes from the layout's entry point but doesn't need ChatView.

To change how <ChatView/> navigates rather than replace it, pass deriveWorkspaceNavigation to ChatView. See Changing navigation behaviour.

Used in