# Saving and restoring the layout

> **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/).

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](https://getstream.io/chat/docs/sdk/react/v15/getting-started/quick-start/#add-threads). [Saving and restoring a layout](https://getstream.io/chat/docs/sdk/react/v15/ui/layout/workspace-with-chatview/#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:

```ts title="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](https://getstream.io/chat/docs/sdk/react/v15/ui/layout/workspace-with-chatview/#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:

```tsx title="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:

```tsx title="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](https://getstream.io/chat/docs/sdk/react/v15/ui/layout/workspace-with-chatview/#what-isnt-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:

```tsx title="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](https://getstream.io/chat/docs/sdk/react/v15/ui/layout/workspace-with-chatview/#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](https://getstream.io/chat/docs/sdk/react/v15/getting-started/quick-start/#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.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/v15/recipes/layout/saving-and-restoring-the-layout/](https://getstream.io/chat/docs/sdk/react/v15/recipes/layout/saving-and-restoring-the-layout/).