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

Single-pane mobile with back button

On a phone there's room for one thing at a time. This recipe shows the channel list, then the channel the user picks, then a thread opened from it, each one at full width. The back button in the header goes back one step, and so do the browser's back button and the back gesture.

It starts from the app in Add threads. The threads view works the same way: the thread list, then the thread the user picks.

One slot

src/App.tsx
import { useEffect } from "react";
import type { ReactNode } from "react";
import { ChannelPaginator } from "stream-chat";
import type {
  ChannelFilters,
  SortParamRequest,
  Thread as StreamThread,
} from "stream-chat";
import {
  Button,
  Channel,
  ChannelHeader,
  ChannelNavigation,
  Chat,
  MessageComposer,
  MessageList,
  Thread,
  ThreadHeader,
  ThreadList,
  useChannelInstanceContext,
  useComponentContextIcons,
  useCreateChatClient,
  useThreadContext,
  useWorkspaceNavigation,
  useWorkspacePanel,
  WorkspaceNavigationBackButton,
} from "stream-chat-react";
import {
  ChatView,
  Slot,
  useActiveThread,
  useLayoutViewState,
} from "stream-chat-react/slot-layout";
import type {
  ChatViewSlotRenderers,
  DeriveWorkspaceNavigation,
  LayoutDescriptor,
} from "stream-chat-react/slot-layout";
import { BrowserBack } from "./BrowserBack";
import "stream-chat-react/dist/css/index.css";
import "./layout.css";

const apiKey = "YOUR_API_KEY";
const userId = "YOUR_USER_ID";
const userToken = "YOUR_USER_TOKEN";

const filters: ChannelFilters = {
  members: { $in: [userId] },
  type: "messaging",
};
const sort: SortParamRequest[] = [{ direction: -1, field: "last_message_at" }];

// Each view has a single slot. A channel or a thread opens there, at full width.
const layouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["main"] },
  { id: "threads", slots: ["main"] },
];

// In the channels view, a thread opens on top of its channel, so back returns
// to the channel. In the threads view, it replaces the thread before it.
const deriveWorkspaceNavigation: DeriveWorkspaceNavigation = (base) => ({
  openThread: (target, options) =>
    base.openThread(target, {
      ...options,
      layer: options?.layer ?? !base.isThreadsView,
    }),
});

// Goes back one step. Over a channel, the SDK's back button removes the thread
// on top. Anywhere else, back closes the panel, and the list shows again.
const BackButton = () => {
  const { IconArrowLeft } = useComponentContextIcons();
  const { canGoBack, closeChannel, closeThread } = useWorkspaceNavigation();
  const panel = useWorkspacePanel();
  const thread = useThreadContext();
  const { channel } = useChannelInstanceContext();

  if (canGoBack(thread ? thread.id : channel?.cid, panel)) {
    return <WorkspaceNavigationBackButton />;
  }

  return (
    <Button
      appearance="ghost"
      aria-label="Back to the list"
      circular
      onClick={(event) => {
        if (thread) closeThread(thread.id, { event, panel });
        else closeChannel(channel?.cid, { event, panel });
      }}
      size="md"
      variant="secondary"
    >
      <IconArrowLeft />
    </Button>
  );
};

// Takes the place of the thread header's close button, which would do what back does.
const NoButton = () => null;

const ThreadContent = ({ thread }: { thread: StreamThread }) => {
  // Marks the thread as on screen while the window is visible and focused.
  useActiveThread({ activeThread: thread });
  return (
    <Thread thread={thread}>
      <ThreadHeader EndContent={NoButton} StartContent={BackButton} />
      <MessageList />
      <MessageComposer />
    </Thread>
  );
};

const slotRenderers: ChatViewSlotRenderers = {
  channel: ({ source }) => (
    <Channel channel={source}>
      <ChannelHeader StartContent={BackButton} />
      <MessageList />
      <MessageComposer />
    </Channel>
  ),
  thread: ({ source }) => <ThreadContent thread={source} />,
};

// Shows the view's list until something opens in its slot, then only the slot.
const SinglePane = ({ list }: { list: ReactNode }) => {
  const { slotBindings } = useLayoutViewState();

  if (slotBindings.main) return <Slot slot="main" />;

  return (
    <>
      <ChatView.Selector />
      {list}
    </>
  );
};

const App = () => {
  const client = useCreateChatClient({
    apiKey,
    tokenOrProvider: userToken,
    userData: { id: userId },
  });

  useEffect(() => {
    if (!client) return;
    const paginator = new ChannelPaginator({
      client,
      filters,
      id: "my-channels",
      sort,
    });
    client.channelManager.insertPaginator({ paginator });
    return () => {
      client.channelManager.removePaginator(paginator);
    };
  }, [client]);

  if (!client) return <div>Connecting...</div>;

  return (
    <Chat client={client}>
      <ChatView
        deriveWorkspaceNavigation={deriveWorkspaceNavigation}
        layouts={layouts}
        slotRenderers={slotRenderers}
        views={{
          channels: <SinglePane list={<ChannelNavigation />} />,
          threads: <SinglePane list={<ThreadList />} />,
        }}
      >
        <BrowserBack />
      </ChatView>
    </Chat>
  );
};

export default App;
src/layout.css
html,
body,
#root {
  height: 100%;
}

body {
  margin: 0;
}

/* The list fills the width beside the view selector. */
.str-chat__chat-view .str-chat__channel-list,
.str-chat__chat-view .str-chat__thread-list-container {
  flex: 1 1 auto;
}

/* Below 768px, the SDK's stylesheet hides the lists. Here the list is the only
   thing on screen until something opens, so it stays visible. */
@media (max-width: 767px) {
  .str-chat__chat-view .str-chat__channel-list,
  .str-chat__chat-view .str-chat__thread-list-container {
    inset: auto;
    pointer-events: auto;
    position: relative;
    transform: none;
    visibility: visible;
  }
}

BrowserBack is in The browser's back button below.

Each view has one slot, main. SinglePane decides what's on screen:

  • While main is empty, it shows the view selector and the view's list: ChannelNavigation in the channels view, ThreadList in the threads view.
  • Once something opens in main, it shows only the slot. A click on a channel in the list opens it there, because it's the only slot. A click on a thread in the thread list does the same in the threads view.

In the channels view, a thread opens on top of its channel. deriveWorkspaceNavigation adds layer: true to openThread while the channels view is shown (base.isThreadsView is false). Without it, the thread would replace the channel in the one slot, and back couldn't return to it. In the threads view, a thread replaces the one before.

The slot is rendered with Slot and slotRenderers, because only Slot shows what's on top of a slot. ChannelSlot and ThreadSlot would show the channel and never the thread over it. See Rendering a slot.

Below 768px, the SDK's stylesheet hides the channel list and the thread list, and leaves showing them to the app. Here the list is all there is on screen until something opens, so the media query shows it again.

This layout is single-pane at every width. To use it only on phones, choose it once when the app loads, for example with window.matchMedia("(max-width: 767px)").matches. ChatView starts its layout again when layouts changes, which closes everything that's open, so don't switch layouts on resize.

Back navigation

Each view is a stack: the list at the bottom, then a channel, then a thread on top of it. Back goes one step down:

  1. From a thread on top of its channel, back removes the thread, and the channel shows again. Its messages and the message being written are still there: the channel's state keeps them.
  2. From a channel, or from a thread in the threads view, back closes the panel. The slot is empty again, so SinglePane shows the list.

Opening the channel from its thread also closes the thread. For example, View next to "Also sent in channel" jumps from the thread to the reply in the channel. The channel replaces itself in the slot, and that closes the thread on top of it. See Reply also sent in channel.

The browser's back button

On a phone, users go back with the system's back button or gesture, which goes back in the browser's history. BrowserBack adds a history entry for each step opened, and takes a step back when the browser goes back:

src/BrowserBack.tsx
import { useEffect } from "react";
import {
  getLayoutViewState,
  useChatViewContext,
  useLayoutViewState,
} from "stream-chat-react/slot-layout";
import type { ChatViewLayoutViewState } from "stream-chat-react/slot-layout";

// How many steps are open in the "main" slot: what it holds, and what's on top.
const stepsOpen = ({ slotBindings, slotLayers }: ChatViewLayoutViewState) =>
  (slotBindings.main ? 1 : 0) + (slotLayers?.main?.length ?? 0);

// The steps the current history entry was added for.
const stepsInHistory = () =>
  (window.history.state as { steps?: number } | null)?.steps ?? 0;

export const BrowserBack = () => {
  const { layoutController } = useChatViewContext();
  const steps = stepsOpen(useLayoutViewState());

  // A step opened: add an entry. Steps closed from the header: drop their entries.
  useEffect(() => {
    const inHistory = stepsInHistory();
    if (steps > inHistory) window.history.pushState({ steps }, "");
    else if (steps < inHistory) window.history.go(steps - inHistory);
  }, [steps]);

  // The browser went back: close the top step.
  useEffect(() => {
    const onPopState = () => {
      const view = getLayoutViewState(layoutController.state.getLatestValue());
      if (stepsOpen(view) <= stepsInHistory()) return;
      if (view.slotLayers?.main?.length) layoutController.popLayer("main");
      else layoutController.release("main");
    };

    window.addEventListener("popstate", onPopState);
    return () => window.removeEventListener("popstate", onPopState);
  }, [layoutController]);

  return null;
};

Here is what happens as the user moves through the app:

  1. The user opens a channel, then a thread from it. Each step adds a history entry, which records how many steps are open: 1, then 2.
  2. The user goes back with the system gesture. The browser goes to the entry for 1 step and fires popstate. Two steps are open, more than the entry records, so BrowserBack removes the top one, the thread.
  3. The user taps back in the channel's header. The channel closes, so 0 steps are open, fewer than the entry records. BrowserBack goes back in history by the difference, so the next system back leaves the page rather than doing nothing.

BrowserBack sits in ChatView's children, which render in every view. It counts the steps of the view that's shown. It calls the layout controller, not useChatViewNavigation(): the navigation's functions change with every layout change, while the controller is the same object. popLayer removes what's on top of the slot, and release empties it. See Hiding vs closing.

BrowserBack keeps its count in history.state. With a router, which keeps its own data there, add the entries through the router instead.

The header back button

ChannelHeader and ThreadHeader start with WorkspaceNavigationBackButton by default. It shows only when the panel has something beneath what it shows, which canGoBack from useWorkspaceNavigation() answers. Pressing it calls goBack, which removes the top and shows what's beneath. In this layout, that's a thread on top of its channel.

A channel, and a thread in the threads view, have nothing beneath them, so the SDK's button renders nothing there. BackButton, passed to both headers as StartContent, covers both cases:

  • When canGoBack is true, it renders WorkspaceNavigationBackButton.
  • Otherwise it renders its own button, which closes the panel: closeChannel for a channel, closeThread for a thread. panel, from useWorkspacePanel(), says which panel to close.

The button is the SDK's Button, with the arrow icon from useComponentContextIcons(), so it looks like the SDK's back button and follows your icon overrides.

ThreadHeader also ends with a close button. Over a channel, it would do what back does, so EndContent={NoButton} leaves it out. The channel's close button shows only on a channel opened beside another one, so it never shows here.

Back and close lists what the SDK's header buttons do in every layout.