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

Channel list paginators

A ChannelPaginator holds the channels of a channel list. It is a query for channels, with filters, a sort and request options, and the channels it has loaded. It is the one kind of paginator you create yourself, and ChannelList renders it. Registered on client.channelManager, it stays current as messages and other events arrive.

An app can have several.

Info:

Example

The user's app shows their pinned channels, their inbox and their archive, each a ChannelPaginator with its own filters. One manager keeps all three current from one set of event listeners, and moves a channel from one paginator to another when it changes.

This page builds on How pagination works, which explains loading, staying live and ordering for every paginator. Rendering a paginator is on Channel list UI.

Creating a channel paginator

import { ChannelPaginator } from "stream-chat";
import type { ChannelFilters, SortParamRequest, StreamChat } from "stream-chat";

const latestFirst: SortParamRequest[] = [
  { direction: -1, field: "last_message_at" },
];

export const createInbox = (client: StreamChat, userId: string) => {
  const inboxFilters: ChannelFilters = {
    archived: false,
    members: { $in: [userId] },
    type: "messaging",
  };

  return new ChannelPaginator({
    client,
    filters: inboxFilters,
    id: "inbox",
    paginatorOptions: { pageSize: 30 },
    requestOptions: { message_limit: 25, presence: true },
    sort: latestFirst,
  });
};

The options:

  • id names the paginator. Ownership rules and ChannelManager.getPaginatorById() use it. Without one, the paginator gets a random id.
  • filters are sent with every page, and the client matches channels against them as events arrive.
  • sort orders the channels. The default is last_message_at, then updated_at, both newest first.
  • requestOptions go with every page request: message_limit, member_limit, presence, state, watch and the other queryChannels options. limit and offset are left out, because the paginator sets them.
  • paginatorOptions tune the paginator: pageSize (10 by default), debounceMs, retryCount (3 by default), lockItemOrder and throwErrors.

Each page is a queryChannels request. It watches the channels it returns, unless requestOptions.watch is false.

Create a paginator once for as long as its screen needs it, for example in a useMemo keyed on the client, not on every render. ChannelList loads the first page of a paginator that has none, so a new paginator on every render queries again each time.

Registering paginators

A paginator stays current only while it's registered on client.channelManager. Register your paginators with ChannelManager.setPaginators(), in the order the UI should show them:

import { ChannelPaginator } from "stream-chat";
import type { StreamChat } from "stream-chat";

export const registerPaginators = (client: StreamChat, userId: string) => {
  const mine = { members: { $in: [userId] } };

  // Replaces the whole set in one update, in this order. A registered paginator left out is
  // removed, and a repeated id keeps only its first occurrence.
  client.channelManager.setPaginators([
    new ChannelPaginator({
      client,
      filters: { ...mine, pinned: true },
      id: "pinned",
    }),
    new ChannelPaginator({
      client,
      filters: { ...mine, archived: false },
      id: "inbox",
    }),
    new ChannelPaginator({
      client,
      filters: { ...mine, archived: true },
      id: "archive",
    }),
  ]);
};

To add or remove one paginator at a time, use ChannelManager.insertPaginator() and ChannelManager.removePaginator():

import { ChannelPaginator } from "stream-chat";
import type { StreamChat } from "stream-chat";

export const registerInboxAndArchive = (client: StreamChat, userId: string) => {
  const inbox = new ChannelPaginator({
    client,
    filters: { archived: false, members: { $in: [userId] } },
    id: "inbox",
  });
  const archive = new ChannelPaginator({
    client,
    filters: { archived: true, members: { $in: [userId] } },
    id: "archive",
  });

  // Adds a paginator at `index`, or at the end without one. A paginator with the same id
  // moves to the new position.
  client.channelManager.insertPaginator({ paginator: inbox });
  client.channelManager.insertPaginator({ paginator: archive, index: 0 });

  // Returns the registered paginator with that id, the same object, not a copy.
  const registeredArchive = client.channelManager.getPaginatorById("archive");
  console.log(registeredArchive === archive); // true

  // When the paginators are no longer shown. A removed paginator stops getting event updates
  // and keeps the channels it loaded, so it could be registered again. These two are done for
  // good, so `dispose()` lets go of their channels.
  return () => {
    client.channelManager.removePaginator(inbox);
    client.channelManager.removePaginator(archive);
    inbox.dispose();
    archive.dispose();
  };
};

client.channelManager.state holds the registered paginators as paginators, in their registration order, and publishes when one is registered, moved or removed. client.channelManager.paginators reads the current set once. The SDK has no component that renders all of them. Render one ChannelList for each paginator you show. A screen usually registers its paginators in an effect and removes them when it unmounts, as the sample on Channel list UI does.

A paginator you're done with for good also needs paginator.dispose(). A paginator holds the channels it loaded, so they count as used until it lets go of them. ChannelPaginator.dispose() does that, and the cleanup of unused channels can then drop them. ChannelList never disposes a paginator.

Registering a paginator loads nothing. The paginator loads its first page when something calls ChannelPaginator.toTail() or ChannelPaginator.reload(). ChannelList calls ChannelPaginator.reload() whenever its paginator has nothing loaded and a user is connected, for example when it first mounts or when it's given a new paginator. The manager handles events only while its subscriptions are registered. Each mounted ChannelList registers them, and they stop when the last one unmounts. Code that keeps paginators current while no ChannelList is mounted calls client.channelManager.registerSubscriptions(), which returns the function that unregisters them.

// Routes events to the registered paginators from now on.
const unregister = client.channelManager.registerSubscriptions();

// When the app no longer needs live channel lists, for example when the user logs out.
// Registrations are counted, so the manager stops listening only once every one is
// released, including those of mounted `ChannelList` components.
unregister();

How events move channels between paginators

When an event changes a channel, the manager routes it. The channel joins every registered paginator whose filters it matches, at its sort position, and leaves the others.

Events What the manager does
message.new, notification.message_new, notification.added_to_channel, channel.visible, channel.hidden, member.updated Routes the channel. A channel the client doesn't have yet is loaded first (see the next section).
channel.updated, channel.truncated, message.deleted with hard_delete Routes the channel if the client has it. A changed field, such as the name or the latest message, can move or drop it.
channel.deleted, notification.removed_from_channel Removes the channel from every paginator.
message.read, message.read_locally, notification.mark_read, notification.mark_unread Routes the channel for the current user's own reads of the channel, not of a thread, and only when a paginator filters on has_unread or sorts by has_unread or unread_count.
notification.channel_mutes_updated Routes every loaded channel, so paginators filtered on muted follow the change.
Info:

Example

The user archives "Design" with Channel.archive(). Nothing moves yet. The server sends member.updated, which sets their membership's archived_at. The manager routes "Design". It no longer matches the inbox, { archived: false }, and it matches the archive, { archived: true }, so it leaves the inbox and joins the archive.

The client matches these channel fields:

  • archived and pinned from the user's membership, muted from their mute list, and has_unread from their read state;
  • members from the loaded members, the current user always included, and member.user.name from the loaded members;
  • hidden, type, id, cid, last_updated, app_banned, and any field of the channel's data, custom fields included.

A hidden channel never matches a paginator whose filters don't mention hidden, as on the server.

Channels the client hasn't seen

Info:

Example

Bob starts a direct conversation with the user. The user's client has never loaded that channel, so notification.added_to_channel names a channel it doesn't have. The channel manager gets the channel, watches it, then routes it, and the conversation appears in the user's inbox.

It does this for every event in the first row of the table above. To stop it, so that the paginators hold only channels they loaded themselves, add the ignoreEventsForUnknownChannels handler at the start of each of those events' pipelines. For a channel the client doesn't have, it ends the event's handling there, so the manager doesn't get or watch the channel and doesn't add it to any paginator:

import { ignoreEventsForUnknownChannels } from "stream-chat";
import type { StreamChat } from "stream-chat";

export const ignoreUnknownChannels = (client: StreamChat) =>
  (
    [
      "channel.hidden",
      "channel.visible",
      "member.updated",
      "message.new",
      "notification.added_to_channel",
      "notification.message_new",
    ] as const
  ).map((eventType) =>
    client.channelManager.addEventHandler({
      eventType,
      handle: ignoreEventsForUnknownChannels,
      id: `app:ignore-unknown-channels:${eventType}`,
      index: 0,
    }),
  );

With this in place, a channel the user was just added to doesn't appear until a paginator loads it, for example on its next ChannelPaginator.reload().

Customizing event handling

The manager keeps one pipeline per event type, an ordered list of handlers. Each event runs through the handlers of its type, in order:

  • a handler receives { event, ctx }, where ctx.channelManager is the manager;
  • returning { action: "stop" } ends the run, so later handlers don't see the event;
  • a handler can be async, and events of one type are handled one at a time, in the order they arrived;
  • a handler that throws is logged, and the next handler still runs.

The client applies an event to the channel's state before the manager sees it, so a handler reads the updated channel.

The built-in handlers have the ids ChannelManager:default-handler:<event type>, for example ChannelManager:default-handler:message.new. The four read events share ChannelManager:default-handler:read-state-changed. ChannelManager.getDefaultHandlers() returns a copy of all of them.

This handler boosts a channel in every paginator for a minute when a new message mentions the current user. It's inserted before the built-in handler, so the channel is placed with its boost:

const unsubscribe = client.channelManager.addEventHandler({
  eventType: "message.new",
  handle: ({ ctx: { channelManager }, event }) => {
    const { cid, message } = event;
    const mentionsMe = message?.mentioned_users?.some(
      ({ id }) => id === client.userId,
    );
    if (!cid || !mentionsMe) return;
    channelManager.paginators.forEach((paginator) =>
      paginator.boost(cid, { ttlMs: 60_000 }),
    );
  },
  id: "app:boost-mentions",
  index: 0,
});

The other methods change a pipeline as a whole:

  • ChannelManager.addEventHandler({ eventType, handle, id, index, replace, revertOnUnsubscribe }) inserts a handler at index, or at the end, and returns the function that removes it. With replace: true it replaces the handler at index, and revertOnUnsubscribe: true puts that one back when you remove yours.
  • ChannelManager.setEventHandlers({ eventType, handlers }) replaces every handler of the event type.
  • ChannelManager.removeEventHandlers({ eventType, handlers }) removes the handlers that match. Each entry in handlers matches by the function, { handler }, or by the id, { idMatch: { id } }, where id can be a RegExp to match several.

For example, this removes the mention booster added above:

client.channelManager.removeEventHandlers({
  eventType: "message.new",
  handlers: [{ idMatch: { id: "app:boost-mentions" } }],
});

Any event type can have a pipeline, custom events included.

Channels that match several paginators

By default, a channel is in every paginator whose filters it matches. The user's pinned paginator, { pinned: true }, and their inbox, { archived: false }, both match a pinned channel, so it's in both.

To keep each channel in one paginator, set an ownership rule. With an array of paginator ids, highest priority first, the matching paginator that ranks highest keeps the channel:

import { ChannelPaginator } from "stream-chat";
import type { StreamChat } from "stream-chat";

export const registerPinnedAndInbox = (client: StreamChat, userId: string) => {
  const pinned = new ChannelPaginator({
    client,
    filters: { members: { $in: [userId] }, pinned: true },
    id: "pinned",
  });
  const inbox = new ChannelPaginator({
    client,
    filters: { archived: false, members: { $in: [userId] } },
    id: "inbox",
  });

  client.channelManager.setPaginators([pinned, inbox]);
  // A pinned channel is in "pinned" only.
  client.channelManager.setOwnershipResolver(["pinned", "inbox"]);

  return { inbox, pinned };
};

A function works too. It receives { channel, matchingPaginators } and returns the ids of the paginators that keep the channel. An empty array keeps it in every matching paginator. This one keeps channels with two members in "inbox" only:

import type { StreamChat } from "stream-chat";

export const keepTwoMemberChannelsInInbox = (client: StreamChat) =>
  client.channelManager.setOwnershipResolver(
    ({ channel, matchingPaginators }) =>
      channel.data?.member_count === 2
        ? matchingPaginators
            .filter(({ id }) => id === "inbox")
            .map(({ id }) => id)
        : [],
  );
  • A paginator missing from the array ranks lowest. When none of the matching paginators is in the array, the channel stays in all of them.
  • ChannelManager.setOwnershipResolver() without an argument goes back to the default.

The rule applies to events and to the pages each paginator loads itself. A page from the server leaves out the channels another paginator owns.

A channel that matches no paginator isn't in any. To catch those, register a paginator that matches every channel of the user, such as { members: { $in: [userId] } }, with the lowest priority.

Changing filters or sort

A paginator's filters and sort can change. Set paginator.staticFilters or paginator.sort, or change the context of paginator.filterBuilder. The paginator then sends and matches the new values, but it keeps the channels it loaded with the old ones. Call ChannelPaginator.reload() after the change, so the paginator starts again from its first page:

paginator.staticFilters = { ...paginator.staticFilters, team: "design" };
paginator.sort = [{ direction: -1, field: "updated_at" }];
await paginator.reload();

Without ChannelPaginator.reload(), the next load keeps the paginator's old position. It starts at the offset the paginator had reached, so it skips the channels before it, and it loads nothing when the paginator had no more channels to load.

Used in