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

Saving and restoring the layout

This recipe makes a reload put the user back where they were: in the view that was shown, with the channel that was open. It also keeps the open channel in the URL, so a link such as ?channel=messaging:general opens that channel.

It starts from the app in Add threads. Saving and restoring a layout explains the two functions it uses.

Serializing the layout

serializeLayoutState turns the layout's state into a snapshot: the view that's shown and, for each view, its slots, what each slot holds, which slots are hidden, and each slot's history.

A slot holds the channel or thread object itself, which can't be stored as JSON. This module turns each channel into its cid and back:

src/layoutSnapshot.ts
import type { Channel as StreamChannel, StreamChat } from "stream-chat";
import {
  createChatViewSlotBinding,
  getChatViewEntityBinding,
} from "stream-chat-react/slot-layout";
import type {
  DeserializeLayoutEntityBinding,
  SerializeLayoutEntityBinding,
} from "stream-chat-react/slot-layout";

// The client's object for the channel with this cid, or nothing for an invalid cid.
export const channelFromCid = (
  client: StreamChat,
  cid: string,
): StreamChannel | undefined => {
  const [type, id] = cid.split(":");
  return type && id ? client.channel(type, id) : undefined;
};

// A channel is saved as its cid. Threads, and anything else, are left out.
export const serializeEntityBinding: SerializeLayoutEntityBinding = (
  binding,
) => {
  const entity = getChatViewEntityBinding(binding);
  if (entity?.kind !== "channel") return undefined;
  return { key: binding.key, payload: { cid: entity.source.cid } };
};

// A saved cid becomes the client's channel object again. Nothing is loaded here.
export const createDeserializer =
  (client: StreamChat): DeserializeLayoutEntityBinding =>
  (saved) => {
    const { cid } = saved.payload as { cid?: string };
    const channel = cid ? channelFromCid(client, cid) : undefined;
    if (!channel) return undefined;
    return createChatViewSlotBinding({
      key: channel.cid,
      kind: "channel",
      source: channel,
    });
  };

serializeEntityBinding is called for each thing a slot holds, and for each entry of a slot's history. It returns what to save, or undefined to leave the entry out.

This app saves channels and leaves threads out. A thread can be built again only with a request, and a deserializer has to return at once. After a reload, the channel opens without the thread that was beside it.

A layout with list slots, such as the built-in workspace layout, has to save its lists too. The restore drops whatever the snapshot leaves out, so its list slots would come back empty. A list binding holds no data, so both functions can return it as it is. The Quick start app renders its lists outside the slots, so it has nothing to save for them.

Restoring on load

restoreLayoutState turns a snapshot back into the layout's state, with createDeserializer turning each saved cid into a channel. This component restores the saved layout when it mounts, and saves the layout after every change:

src/LayoutPersistence.tsx
import { useEffect } from "react";
import { useChatContext } from "stream-chat-react";
import {
  restoreLayoutState,
  serializeLayoutState,
  useChatViewContext,
} from "stream-chat-react/slot-layout";
import type { ChatViewLayoutSnapshot } from "stream-chat-react/slot-layout";
import { createDeserializer, serializeEntityBinding } from "./layoutSnapshot";

export const LayoutPersistence = () => {
  const { client } = useChatContext();
  const { layoutController } = useChatViewContext();

  useEffect(() => {
    // One layout per user. Change the version when you change the app's layouts.
    const storageKey = `chat-layout:v1:${client.userId}`;

    const saved = localStorage.getItem(storageKey);
    if (saved) {
      const snapshot = JSON.parse(saved) as ChatViewLayoutSnapshot;
      layoutController.state.next(
        restoreLayoutState(snapshot, {
          deserializeEntityBinding: createDeserializer(client),
        }),
      );
    }

    // Called once now, then after every change.
    return layoutController.state.subscribe((state) => {
      const snapshot = serializeLayoutState(state, { serializeEntityBinding });
      localStorage.setItem(storageKey, JSON.stringify(snapshot));
    });
  }, [client, layoutController]);

  return null;
};

App renders it inside ChatView, and adds WatchChannel to the channel's slot:

src/App.tsx
import { useEffect } from "react";
import { ChannelPaginator } from "stream-chat";
import type { ChannelFilters, SortParamRequest } from "stream-chat";
import {
  ChannelHeader,
  ChannelNavigation,
  Chat,
  MessageComposer,
  MessageList,
  ThreadHeader,
  ThreadList,
  useChannel,
  useCreateChatClient,
} from "stream-chat-react";
import {
  ChannelSlot,
  ChatView,
  ThreadSlot,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
import { LayoutPersistence } from "./LayoutPersistence";
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" }];

const layouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["channel", "thread"] },
  { id: "threads", slots: ["thread"] },
];

// Watches the channel its slot shows. A channel from the list is watched
// already, and then this sends nothing.
const WatchChannel = () => {
  const channel = useChannel();

  useEffect(() => {
    channel
      .watch()
      .catch((error) => console.error("Could not watch the channel", error));
  }, [channel]);

  return null;
};

const ThreadPanel = () => (
  <ThreadSlot slot="thread">
    <ThreadHeader />
    <MessageList />
    <MessageComposer />
  </ThreadSlot>
);

const ChannelsView = () => (
  <>
    <ChatView.Selector />
    <ChannelNavigation />
    <ChannelSlot slot="channel">
      <WatchChannel />
      <ChannelHeader />
      <MessageList />
      <MessageComposer />
    </ChannelSlot>
    <ThreadPanel />
  </>
);

const ThreadsView = () => (
  <>
    <ChatView.Selector />
    <ThreadList />
    <ThreadPanel />
  </>
);

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
        layouts={layouts}
        views={{ channels: <ChannelsView />, threads: <ThreadsView /> }}
      >
        <LayoutPersistence />
      </ChatView>
    </Chat>
  );
};

export default App;

layout.css stays as in the Quick start.

Here is what happens when the user reloads the page:

  1. LayoutPersistence mounts inside ChatView, among its children, which render in every view. There, useChatViewContext() gives it the layout controller.
  2. It reads the snapshot saved before the reload. restoreLayoutState turns it back into the layout's state, and StateStore.next() on the layout controller's state puts it in place: the view that was shown, and the channel in the channel slot.
  3. ChannelSlot renders the restored channel. It's only the client's object for that channel: nothing has loaded it yet. WatchChannel watches it, which loads its messages, members and read state, and the message list fills in.
  4. StateStore.subscribe() calls its handler at once and after every change. From then on, every open and close saves the layout again.

The channel is watched where it's shown rather than in the deserializer, because the deserializer also runs for every entry of each slot's history. Watching there would watch every channel the user opened before in that slot, after every reload. WatchChannel watches only the channel on screen.

The storage key carries the user's ID, so another user on the same browser doesn't get the layout. It also carries a version. A snapshot keeps the slots it was saved with, so when you change the app's layouts, change the version and an old snapshot isn't restored into the new layout.

Layers aren't saved, and neither are the loaded messages: each restored channel loads again. What isn't saved has the full list.

Syncing with the URL

A snapshot is too long for a URL, and a link should name a channel, not a whole layout. So the URL holds only the cid of the channel in the channel slot: ?channel=messaging:general. This version of LayoutPersistence keeps it there, and opens the channel a link names:

src/LayoutPersistence.tsx
import { useEffect } from "react";
import { useChatContext } from "stream-chat-react";
import {
  createChatViewSlotBinding,
  getChatViewEntityBinding,
  getLayoutViewState,
  restoreLayoutState,
  serializeLayoutState,
  useChatViewContext,
} from "stream-chat-react/slot-layout";
import type {
  ChatViewLayoutSnapshot,
  ChatViewLayoutState,
} from "stream-chat-react/slot-layout";
import {
  channelFromCid,
  createDeserializer,
  serializeEntityBinding,
} from "./layoutSnapshot";

const URL_PARAM = "channel";

// The cid of the channel in the channels view's "channel" slot.
const cidInChannelSlot = (state: ChatViewLayoutState) => {
  const entity = getChatViewEntityBinding(
    getLayoutViewState(state, "channels").slotBindings.channel,
  );
  return entity?.kind === "channel" ? entity.source.cid : undefined;
};

// Puts the cid in the URL, or takes it out, without adding a history entry.
const writeCidToUrl = (cid: string | undefined) => {
  const url = new URL(window.location.href);
  if ((url.searchParams.get(URL_PARAM) ?? undefined) === cid) return;
  if (cid) url.searchParams.set(URL_PARAM, cid);
  else url.searchParams.delete(URL_PARAM);
  window.history.replaceState(window.history.state, "", url);
};

export const LayoutPersistence = () => {
  const { client } = useChatContext();
  const { layoutController } = useChatViewContext();

  useEffect(() => {
    // One layout per user. Change the version when you change the app's layouts.
    const storageKey = `chat-layout:v1:${client.userId}`;

    const saved = localStorage.getItem(storageKey);
    if (saved) {
      const snapshot = JSON.parse(saved) as ChatViewLayoutSnapshot;
      layoutController.state.next(
        restoreLayoutState(snapshot, {
          deserializeEntityBinding: createDeserializer(client),
        }),
      );
    }

    // A link to a channel other than the restored one opens that channel.
    const cidInUrl = new URLSearchParams(window.location.search).get(URL_PARAM);
    const linkedChannel = cidInUrl
      ? channelFromCid(client, cidInUrl)
      : undefined;
    if (
      linkedChannel &&
      linkedChannel.cid !==
        cidInChannelSlot(layoutController.state.getLatestValue())
    ) {
      layoutController.setActiveView("channels");
      layoutController.bind(
        "channel",
        createChatViewSlotBinding({
          key: linkedChannel.cid,
          kind: "channel",
          source: linkedChannel,
        }),
      );
    }

    // Called once now, then after every change.
    return layoutController.state.subscribe((state) => {
      const snapshot = serializeLayoutState(state, { serializeEntityBinding });
      localStorage.setItem(storageKey, JSON.stringify(snapshot));
      writeCidToUrl(cidInChannelSlot(state));
    });
  }, [client, layoutController]);

  return null;
};

It adds two things to the first version:

  • After every change, writeCidToUrl puts the cid of the open channel in the URL. history.replaceState changes the URL without adding a history entry, so the browser's back button still leaves the page.
  • On load, after the restore, it reads the cid in the URL. When it names a channel other than the restored one, the channels view is shown and the channel goes into the channel slot. WatchChannel then watches it, as it does a restored channel.

On a reload, the URL names the restored channel, so nothing changes, and the rest of the layout, such as the threads view being shown, comes back too. Only a link to a different channel replaces it.

LayoutController.bind() puts the channel in the slot directly. At load, no thread is open beside the channel, because threads aren't saved, so that's all opening it takes. After load, open channels with openChannel from useWorkspaceNavigation(), which also closes the thread beside the channel it replaces.

The effect uses the layout controller rather than openChannel for the same reason as List slots does: the navigation's functions change with every layout change. An effect that depended on openChannel would run again after every change, and restore the layout again each time.

A link names any channel, so treat it like user input:

  • A channel the user can't read opens empty: its watch fails, and WatchChannel logs the error.
  • A channel that doesn't exist is created by the watch, when the user is allowed to create channels of its type. Watching gets the channel or creates it, as in Show one channel.

To open only channels the user is a member of, query the linked channel first, with a filter on its cid and its members, and put it in the slot once the query returns it.