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

How pagination works

A paginator is a state layer object that loads items page by page and keeps them current as events arrive. Every paginator in stream-chat works the same way:

Paginator Holds Created by Details
ChannelPaginator the channels of one channel list your code Channel list paginators
channel.messagePaginator a channel's messages the channel Message paginators
thread.messagePaginator a thread's replies the thread Message paginators
channel.pinnedMessagesPaginator a channel's pinned messages the channel Pinned messages paginator
client.threads.paginator the user's threads the client Thread and thread manager
client.reminders.paginator the user's reminders the client Reminder manager

This page explains what they have in common. Their methods and options are listed in Channel manager and ChannelPaginator and Message paginator.

Defined by filters and sort

Info:

Example

A paginator of the user's messaging channels, the most recently active first, has two parts:

  • the filters, { members: { $in: [userId] }, type: "messaging" }, decide which channels belong to the paginator;
  • the sort, [{ field: "last_message_at", direction: -1 }], decides their order.

If either changes, the items already loaded no longer match the paginator. The paginator has to load again from the beginning: the next load is a first page, and the old items are discarded. For a ChannelPaginator you do this with ChannelPaginator.reload(), as Changing filters or sort explains.

The message paginators, for a channel's messages, a thread's replies and the pinned messages, have a fixed definition. Their channel or thread sets it, and you can't change it.

Which paginators take filters and sort:

Paginator Filters and sort
ChannelPaginator Yours: filters and sort when you create it, or later through staticFilters, filterBuilder and sort. A change doesn't reload by itself: call ChannelPaginator.reload().
client.reminders.paginator Yours: set filters or sort on it. The default sort is created_at, oldest first. Setting either one clears the loaded reminders, so the next load starts from the first page.
client.threads.paginator None. The server decides the order: unread threads first, then by latest reply.
channel.messagePaginator, thread.messagePaginator Fixed: the channel's or thread's messages, by created_at.
channel.pinnedMessagesPaginator Fixed: the channel's pinned messages, by pinned_at.

Intervals

A paginator keeps what it has loaded as intervals. An interval is a collection of pages of items retrieved from the server during pagination, with nothing missing between them. Usually there is one: the first page and every page loaded after it join it.

A second interval appears when a message paginator jumps to a message that isn't loaded, far from what is. The page around that message can't join the loaded messages, because the ones in between are missing, so it becomes an interval of its own. When later loads close the gap, the two intervals join.

items in the paginator's state holds one interval, the one on screen. Message paginators shows how jumps move between intervals.

Head and tail

Every paginator has two ends:

  • the head is the end the first page loads from;
  • the tail is the other end. Later pages load toward it.

Which item is at the head depends on the paginator's sort.

A ChannelPaginator sorted by latest activity has the most recently active channel at its head, the top of the channel list. A channel that gets a new message sorts first, so it moves there, and scrolling down loads more channels toward the tail.

A message paginator loads the newest messages first.

The reminder paginator sorts by created_at, oldest first by default, so its head is the oldest reminder.

Loading more

Every paginator loads more the same way: paginator.toTail() loads the next page toward the tail, and paginator.toHead() toward the head. A paginator loads its first page from its head, so usually only the tail has more.

Every paginator also keeps its data in a state store with the same fields:

Field What it holds
items The loaded items of the interval on screen. undefined until a page loads.
isLoading Whether a page is loading.
hasMoreTail Whether there are more items toward the tail, beyond the interval in items.
hasMoreHead Whether there are more items toward the head, beyond the interval in items. Usually false, but true after a message paginator jumps to an older interval, until MessagePaginator.toHead() loads up to the newest messages.
lastQueryError The error of the last failed load. A successful load clears it.

This paginator loads the user's messaging channels and logs every change:

import { ChannelPaginator } from "stream-chat";

const inbox = new ChannelPaginator({
  client,
  filters: { members: { $in: [userId] }, type: "messaging" },
  sort: [{ direction: -1, field: "last_message_at" }],
});

// The handler runs once right away, then whenever one of the selected fields changes.
const unsubscribe = inbox.state.subscribeWithSelector(
  ({ hasMoreTail, isLoading, items, lastQueryError }) => ({
    hasMoreTail,
    isLoading,
    items,
    lastQueryError,
  }),
  ({ hasMoreTail, isLoading, items, lastQueryError }) => {
    if (lastQueryError) console.error("Couldn't load channels", lastQueryError);
    console.log(`${items?.length ?? 0} channels`, { hasMoreTail, isLoading });
  },
);

await inbox.toTail(); // the first page
await inbox.toTail(); // the next page

A few rules apply to every load:

  • One load at a time. While a page is loading, another paginator.toTail(), paginator.toHead() or paginator.reload() returns at once without loading anything, and isn't retried later. So does a load toward an end that has no more items.
  • Debounced loads for scrolling. The rule above only stops calls while a request is in flight. Once a page arrives, the next scroll event at the edge would load another page right away, and a scroll handler fires many times a second. paginator.toTailDebounced() and paginator.toHeadDebounced() wait for calls to stop for debounceMs (300 ms by default), then load once, so a scroll handler can call them on every scroll event.
  • Retries. A failed request is retried retryCount times, one second apart, and lastQueryError is set only once the last attempt fails. The default is 0, and a ChannelPaginator retries 3 times.
  • Errors. A failed load, whether the request fails or placing its page does, sets lastQueryError and doesn't throw. Pass throwErrors: true in the paginator options to make paginator.toTail(), paginator.toHead() and paginator.reload() reject instead.
  • Reload. paginator.reload() clears the paginator and loads its first page again. Until that page arrives, items is undefined and isLoading is true, so a list rendered from it shows as loading.

The debounce delay, the retries and whether a failed load rejects are configurable per paginator type, with debounceMs, retryCount and throwErrors.

After a reconnect, the client refreshes each ChannelPaginator that has loaded, without clearing it: ChannelPaginator.recoverLoadedItems() queries again, page by page, the channels it has loaded, up to 150. They get their latest data and sort position, channels that now match join, and the list stays on screen meanwhile. Then the loaded channels no page returned are removed, and those beyond the first 150 are dropped until the list is scrolled to them again. What reloads after a reconnect explains when that happens.

Staying live

A paginator doesn't query the server again for every event. When an event changes an item, the client checks the item against the paginator's own filters, and the item joins the paginator, moves, or leaves it.

Info:

Example

The user has an inbox paginator with { archived: false } and an archive paginator with { archived: true }. They archive "Design". The server answers with a member.updated event that sets their membership's archived_at. "Design" no longer matches the inbox, so it leaves the inbox and joins the archive at its sort position.

The client supports these filter operators: $eq, $ne, $in, $nin, $gt, $gte, $lt, $lte, $exists, $contains and $autocomplete, combined with $and, $or and $nor.

Avoid other operators in a paginator's filters. The server still applies them when it loads pages, so the first pages look right. But when an event changes a loaded item, the client checks the item against the filters itself, and a filter it can't evaluate always fails. For example, when a channel in the list gets a new message, it disappears from the list, even though it still matches.

An item that sorts after the last loaded item can't be placed exactly, because unloaded items may come before it. If it's already in the paginator, it stays after the last loaded item until the next page places it. If it newly matches the filters, it isn't added to items until a page reaches its position.

Ordering and boosting

Items follow the paginator's sort. A ChannelPaginator usually sorts by activity, such as last_message_at, so channels change places as messages arrive. Two settings control that:

  • lockItemOrder. With true, a channel already in items keeps its position when it's updated, instead of moving to its new sort position. Use it when rows moving under the user's pointer are worse than a briefly stale order.
  • Boosting. paginator.boost(cid, { ttlMs }) puts a channel first in items until the boost expires, 15 seconds by default. It only reorders the loaded channels: boosting a channel that isn't loaded doesn't add it. The boost takes effect the next time the paginator publishes its items, and has no effect with lockItemOrder. The client never boosts on its own.

This paginator keeps its order while the user reads it:

import { ChannelPaginator } from "stream-chat";

const inbox = new ChannelPaginator({
  client,
  filters: { members: { $in: [userId] } },
  paginatorOptions: { lockItemOrder: true },
});

This function keeps a channel the user just opened first in a paginator for a minute, if it's among the channels the paginator has loaded:

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

const keepOnTop = (paginator: ChannelPaginator, opened: Channel) => {
  paginator.boost(opened.cid, { ttlMs: 60_000 });
  // Places the channel again, so the boost shows now instead of on its next event.
  client.channelManager.ingestChannel(opened);
};

Local and optimistic items

A paginator can hold items the server hasn't confirmed yet. When the user sends "Hello", the message joins the channel's message paginator at once with status sending. When the server answers, its copy replaces the local one and the status becomes received. If the request fails, the message stays in the paginator as failed, ready to be retried.

Items are shared between paginators. A message is one object for every paginator that holds it, so a change made anywhere shows everywhere at once. For example, the user reacts to the parent message of a thread: the reaction shows in the channel's message paginator and in the thread's.

When the server answers, its copy replaces the local one, unless a newer copy arrived in the meantime, for example in an event.

A ChannelPaginator has no optimistic changes. A channel moves when the server's event arrives, not when the call is made: archiving moves it on member.updated. What's optimistic and what isn't lists every action and how it settles.

Paginators vs one-off queries

One-shot queries

These methods return plain data, once. The data isn't added to any paginator, isn't cached, and nothing updates it:

  • channels and threads: client.queryChannels(), client.queryThreads();
  • users and members: client.queryUsers(), Channel.queryMembers();
  • messages: client.getMessage(), client.getReplies(), Channel.getPinnedMessages(), client.search();
  • what belongs to messages: client.queryReactions(), client.queryReminders(), client.queryDrafts().

The result is a snapshot. A new member doesn't join the result of Channel.queryMembers(), and a deleted message doesn't leave the result of client.search(). To refresh a result, run the query again.

// A snapshot: which of these users exist right now.
const { users } = await client.queryUsers({
  payload: { filter_conditions: { id: { $in: ["ann", "bob"] } } },
});

client.queryChannelsAndHydrate(), client.queryThreadsAndHydrate() and client.getThreadAndHydrate() return Channel and Thread objects instead. They add nothing to a paginator either, and the list they return doesn't change. The channels are the client's own, kept in its store and updated by events. A thread is the client's own only when the client already held it; see Shared entities.

Paginators

Paginators stay current as events arrive, load in both directions, and a message paginator can jump to any message. They also share their items with the rest of the state layer, so a message edited in one paginator changes in every other.

Which to use

  • To render something and keep it current, use a paginator: a ChannelPaginator, a channel's or a thread's messagePaginator, channel.pinnedMessagesPaginator, or client.threads.paginator.
  • For a lookup or a snapshot, such as checking whether a user exists or exporting a channel's members, use a query.
  • For search results, use Search sources. They load page by page like a paginator, but don't stay current.

Used in