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

Channel list paginators

A ChannelPaginator holds the channels of a channel list: 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. 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.

Creating a channel paginator

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

const inboxFilters: ChannelFilters = {
  archived: false,
  members: { $in: [userId] },
  type: "messaging",
};
const latestFirst: SortParamRequest[] = [
  { direction: -1, field: "last_message_at" },
];

const inbox = 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.

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";

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";

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

// Later, when the archive is no longer shown. It stops getting event updates and keeps the
// channels it loaded, so it can be registered again.
client.channelManager.removePaginator("archive");

To render a list for each registered paginator, subscribe to client.channelManager.state and select paginators. It holds them in their registration order, and publishes when one is registered, moved or removed. client.channelManager.paginators reads the current set once.

Registering a paginator loads nothing. The paginator loads its first page when something calls ChannelPaginator.toTail() or ChannelPaginator.toTailDebounced(). ChannelList calls ChannelPaginator.toTailDebounced() whenever its paginator has nothing loaded, including after a reload.

The manager handles events once its subscriptions are registered: the React SDK does that for you. Code that uses stream-chat alone 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: the manager stops listening once every one is released.
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 }. 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: the manager doesn't get or watch the channel, and doesn't add it to any paginator.

import { ignoreEventsForUnknownChannels } from "stream-chat";

const unsubscribers = (
  [
    "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 logged in 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";

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"]);

// A function works too: ({ channel, matchingPaginators }) => string[] returns the ids of the
// paginators that keep the channel. An empty array keeps it in every matching paginator.
// This one keeps direct messages in "inbox" only.
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 mixes channels that match the new filters into the channels it already holds, and can skip channels.

Used in