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

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

You create a ChannelPaginator and pass it to ChannelList. The other paginators exist as soon as their channel, thread or client does.

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. Pass filters and sort when you create it, or change them later through staticFilters, filterBuilder and sort. A change doesn't reload by itself, so call ChannelPaginator.reload().
client.reminders.paginator Yours. Set filters or sort on it. Without a sort, the request sends none, so the server decides which reminders the first page holds, and the paginator orders what it loads by 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, and 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. Its head is the newest message, at the bottom of the message list, and scrolling up loads older messages toward its tail.

The reminder paginator orders its reminders by created_at, oldest first, unless you set a sort, so its head is the oldest reminder it has loaded.

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.

The SDK's components call these for you. ChannelList loads the first page whenever its paginator has nothing loaded, and the next one when the user scrolls near the end. The message lists load older messages when the user scrolls near the top, and newer ones near the bottom while hasMoreHead is true.

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";
import type { StreamChat } from "stream-chat";

export const loadInbox = async (client: StreamChat, userId: string) => {
  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

  return { inbox, unsubscribe };
};

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. With offline support, a failed request of a channel list that already shows channels keeps them and sets no error.
  • 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 thread list, client.threads.paginator, is the exception. Its ThreadPaginator.reload() loads the first page even while another load is in flight, and keeps the threads on screen until the new page arrives. Its ThreadPaginator.toTail() does nothing before the first ThreadPaginator.reload().

debounceMs, retryCount and throwErrors set the debounce delay, the retries and whether a failed load rejects. A ChannelPaginator takes them in paginatorOptions, and the message paginators and channel.pinnedMessagesPaginator take them from client.config, as Configuration explains.

After a reconnect, the client refreshes each ChannelPaginator registered on client.channelManager that has loaded, without clearing it. It queries every page the list has loaded again, starting from the first, up to 150 channels, and the list stays on screen meanwhile. The loaded channels get their latest data and sort position, and channels that now match join. Loaded channels that no page returned leave the list: they were deleted, hidden or left, or they no longer match the filters. Channels loaded past the first 150 are dropped too, and load again when the user scrolls to them. If a page fails, the refresh stops and removes nothing. What reloads after a reconnect explains when that happens. To run this refresh on one list from your own code, call ChannelPaginator.recoverLoadedItems().

Pulling down on ChannelList refreshes less: it queries the first page again and merges it into the channels the list has, without removing any. To do that from your own code, call ChannelPaginator.toTail({ keepPreviousItems: true, reset: "yes" }).

With offline support, a channel list's first page shows the channels the local database holds for its query at once. The request to the server waits until the client has synced the events it missed, and its page then replaces those channels.

Staying live

A paginator doesn't query the server again for every event. A ChannelPaginator follows events only once it's registered on client.channelManager with ChannelManager.insertPaginator(), and while the manager's subscriptions are on, which a mounted ChannelList does for you (see Channel list paginators). The samples on this page aren't registered, so they load pages but don't follow events. 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 finger 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, so 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";
import type { StreamChat } from "stream-chat";

export const createStableInbox = (client: StreamChat, userId: string) =>
  new ChannelPaginator({
    client,
    filters: { members: { $in: [userId] } },
    paginatorOptions: { lockItemOrder: true },
  });

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

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

export const keepOnTop = (
  client: StreamChat,
  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, and 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, for example, 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-off queries

These methods return plain data, once. The data isn't added to any paginator, and nothing updates it. Two of them have side effects: client.queryChannels() watches the channels it returns unless you pass watch: false, and client.queryUsers() stores the users it returns in client.state.

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

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 channel list or to the thread list 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