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

Navigation

A row in the channel list doesn't know where channels open. When the user clicks it, the row calls openChannel(channel) and leaves the rest to the layout. Every SDK component that opens, closes or highlights a channel or a thread works the same way, through one interface: useWorkspaceNavigation().

The interface is part of stream-chat-react. The layout that implements it, ChatView, is in stream-chat-react/slot-layout. Moving between channels, threads and messages lists every component that uses it, and Workspace with ChatView explains where ChatView puts what they open.

One navigation interface

useWorkspaceNavigation() returns the same object anywhere below the layout. Its members do three things:

  • Open: openChannel(channel, options?) and openThread(target, options?). The target of openThread is a Thread, or { channel, message } for the thread of that parent message.
  • Close and go back: closeChannel(cid?, options?) and closeThread(threadId?, options?) close a panel. goBack(key?, options?) removes a channel or thread that was opened on top of something else, and canGoBack(key?, panel?) says whether there is anything beneath it.
  • Say what's open: openChannels and openThreads list what the panels hold. isChannelActive(cid?) and isThreadActive(threadId?) say whether a channel or thread is on screen: open in a panel and not covered by something opened on top of it. isChannelDismissable and isThreadDismissable say whether its panel can be closed, and isThreadsView whether the thread list's view is the one shown.

For example, this button opens a support channel and shows when it's 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:

  • 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 channel that's already open. A layout with one slot ignores it.
  • layer: true opens on top of what the panel shows. Going back removes it and shows what's beneath. The panel'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.
  • event is 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 is the panel the call comes from. See Panels below.

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 one slot of the layout. 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. A control of your own 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. This app 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

The members, their types and the options are listed in Workspace navigation API.