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

Message paginator

A MessagePaginator is a message list. Every channel has one in channel.messagePaginator, and every thread has one for its replies in thread.messagePaginator. Inside a thread, read the thread's list, not its channel's. This page lists its API in stream-chat.

A message paginator has everything every paginator has, listed in The base paginator. What only message lists do, such as windows, jumping and the unread snapshot, is explained on Message paginators.

Requesting vs. rendering order, jumping, and unread state

A message list keeps its data in five stores, each for one concern, so a subscriber wakes only for the changes it reads:

Store What it holds
state The messages of the interval on screen, and the loading state.
messageFocusSignal The message to highlight after a jump.
unreadStateSnapshot Where the user's unread messages started when the list loaded.
liveViewState Whether the user is viewing the newest messages.
aggregateState The newest message of the channel or thread, whichever interval is on screen.

The SDK creates a list with its channel or thread. Apps don't construct one. The two kinds differ in how they request pages:

Channel list Thread replies
Request The channel query, with messages pagination client.getReplies() for the parent message
requestSort created_at ascending, not sent created_at descending: newest page first
itemOrder created_at ascending created_at ascending
Page size 100 50

requestSort is the order pages are requested in, and itemOrder is the order of items. In both lists, items is oldest first.

These properties identify a list:

Property What it holds
channel The channel. A thread's list holds the thread's channel.
parentMessageId The parent message's id for a thread's list, else undefined.
id The list's id.
requestSort The sort pages are requested with.
itemOrder The order of items.

Reading the message list

state has the base paginator's fields:

Field What it holds
items The messages of the interval on screen, oldest first. undefined until the first page loads.
isLoading A page is loading.
hasMoreTail There are older messages to load.
hasMoreHead There are newer messages to load: the interval on screen isn't the newest one.
lastQueryError The error of the last failed load.
cursor The ids the next pages start from.

Each message is a LocalMessage: its status is "sending", "received" or "failed", and its timestamps are in nanoseconds.

This handler runs whenever the messages on screen or the loading state change:

const { messagePaginator } = channel;

const unsubscribe = messagePaginator.state.subscribeWithSelector(
  ({ hasMoreTail, isLoading, items }) => ({ hasMoreTail, isLoading, items }),
  ({ hasMoreTail, isLoading, items }) => {
    console.log(`${items?.length ?? 0} messages`, { hasMoreTail, isLoading });
  },
);

Two methods look up loaded messages:

Method Returns What it does
getItem(id) LocalMessage or undefined A loaded message, from any interval.
findItemByTimestamp(timestamp, exactTsMatch) LocalMessage or null The last loaded message created at or before timestamp, in nanoseconds, from any interval, newest interval first. With exactTsMatch set to true, only a message created at that time.

Loading more

Method What it does
toTail(params) Loads older messages.
toHead(params) Loads newer messages, after a jump.
toTailDebounced(params) toTail() once calls stop, for a scroll handler.
toHeadDebounced(params) toHead() once calls stop.
setPruningSuspended(suspended) Pauses the memory cap while the user reads near the oldest loaded message.

params is described on The base paginator. MessagePaginator.toTail({ requestOptions: { watch: true } }) on a channel's list also watches the channel with the same request.

The SDK loads a channel's first page itself: Channel.watch() and channel list pages bring the newest messages. A thread's list starts with the replies its thread came with, or loads them when the thread is opened, as Thread wiring explains. Channel.reload() and Thread.reload() refresh the newest loaded messages in place. A reader on an older interval stays there.

Message lists take these settings from client.config. The messagePaginator key applies to channels and threads, and the messagePaginator inside channel or thread overrides it for one of them:

Setting Default What it does
pageSize 100 for a channel, 50 for a thread How many messages a page asks for.
stateThrottleMs 500 Publishes live changes to items at most once per interval. The user's own changes publish at once.
maxLoadedItems none Caps the newest interval. Once it holds more, the oldest are dropped and hasMoreTail becomes true.
unreadReferencePolicy "snapshot" What MessagePaginator.jumpToTheFirstUnreadMessage() reads first. See Unread state. Read when the list is created.
debounceMs, retryCount, throwErrors, lockItemOrder as for every list See Configuration.
client.config.set({
  // every message list
  messagePaginator: { stateThrottleMs: 300 },
  // channels only
  channel: { messagePaginator: { maxLoadedItems: 500 } },
  // threads only
  thread: { messagePaginator: { pageSize: 30 } },
});

Throttling and the memory cap explains both.

Jumping to a message

Method Returns What it does
jumpToMessage(messageId, options) Promise<boolean> Shows the interval that holds the message, loading a page around it if it isn't loaded.
jumpToTheLatestMessage(options) Promise<boolean> Shows the newest interval, loading it if needed. Doesn't highlight anything, unless you pass suppressFocusSignal: false.
jumpToTheFirstUnreadMessage(options) Promise<boolean> Shows the first message the user hasn't read. See Unread state.

Each resolves to true when it got there and false when it didn't. A failed MessagePaginator.jumpToMessage() or MessagePaginator.jumpToTheLatestMessage() also publishes an error notification through client.notifications. A jump that a later jump or reload overtakes resolves to false without a notification: its page stays loaded but isn't shown.

options is optional and takes these fields:

Field What it does
pageSize How many messages to load around the target. Defaults to the page size.
focusReason The reason the focus signal carries: "jump-to-message", "jump-to-first-unread" or "jump-to-latest".
focusSignalTtlMs How long the highlight lasts once the message is on screen. 3000 ms by default.
suppressFocusSignal true jumps without a focus signal.
watchChannel true also watches the channel with the same request, if it isn't watched. Ignored for a thread's replies.

After a jump, messageFocusSignal holds { signal }. signal is { messageId, reason, token, createdAt, ttlMs }, or null. The token changes on every jump, so two jumps to the same message give two signals. The signal stays until it's cleared:

Method What it does
scheduleMessageFocusSignalClear({ token, ttlMs }) Clears the signal after ttlMs, or the signal's own ttlMs. Call it once the message is on screen.
clearMessageFocusSignal({ token }) Clears the signal at once.
emitMessageFocusSignal({ messageId, reason, ttlMs }) Publishes a signal without jumping, and returns it.

With a token, the first two act only while that signal is the current one.

const { messagePaginator } = channel;

const unsubscribe = messagePaginator.messageFocusSignal.subscribeWithSelector(
  ({ signal }) => ({ signal }),
  ({ signal }) => {
    if (!signal) return;
    document.getElementById(signal.messageId)?.scrollIntoView();
    messagePaginator.scheduleMessageFocusSignalClear({ token: signal.token });
  },
);

await messagePaginator.jumpToMessage(messageId);

Unread state

unreadStateSnapshot keeps a copy of the user's unread state, taken when the list loads its first page. Marking the channel read doesn't change it, so the UI can still show where the user left off.

Field What it holds
lastReadAt When the user last read the channel, in nanoseconds, or null.
lastReadMessageId The last message the user read, or null.
firstUnreadMessageId The message the user marked unread from, or null.
unreadCount How many messages were unread.
Method What it does
setUnreadSnapshot(patch) Changes some fields, and returns the new snapshot.
seedUnreadSnapshot() Takes the snapshot again from the channel's current read state, with firstUnreadMessageId: null.
clearUnreadSnapshot() Resets every field. Call it once the user has caught up.

The channel also changes the snapshot: a new message that counts as unread raises unreadCount, notification.mark_unread sets every field, and truncating the channel clears it.

MessagePaginator.jumpToTheFirstUnreadMessage() goes to firstUnreadMessageId when there is one. Otherwise it goes to the first message after lastReadAt, loading a page around that time if needed, and as a last resort to lastReadMessageId. It reads the snapshot first and the user's entry in channel.state.read second. With unreadReferencePolicy: "read-state-only", it reads only channel.state.read.

MessagePaginator.seedUnreadSnapshot() and MessagePaginator.jumpToTheFirstUnreadMessage() read the channel's read state in a thread's list too, not the thread's.

liveViewState holds { isViewingLive }, false until something sets it. While it's true on a channel's list, a new message in the channel doesn't count as unread for the user.

Member What it does
setViewingLive(isViewingLive) Says whether the user is viewing the newest messages.
isViewingLive Reads the current value once.

Live view and unread explains both.

The latest message

items holds only the interval on screen. After a jump to an old message, the newest message is in another interval, and a new message from someone else goes there too, so items doesn't change. A message the user sends switches the paginator to the newest interval, so they see what they sent.

aggregateState holds the newest message of the channel or thread instead: from any loaded interval, or from an event. It updates when a newer message arrives, when that message is edited, reacted to or deleted, and when it's removed, in which case it falls back to the next newest one. It also holds the time of the newest message as the server reports it, for a channel whose newest message isn't loaded. For a channel, thread-only replies don't count, and neither do system messages when the channel type skips them for last_message_at.

It's a store of its own, so it publishes only when the newest message changes, not on every page or scroll. A channel list preview reads it there.

Thread wiring and minimal initialization

A thread's replies are a MessagePaginator with the thread's parent message id. It requests pages from client.getReplies(), newest page first, 50 replies a page, and keeps items oldest first.

Get a thread from client.threads.ensure(), not with new Thread(). A thread created that way knows only its parent message, and its reply list is ready at once:

const thread = client.threads.ensure({ channel, parentMessage: message });

// The reply list and the composer work before anything is loaded.
// For a new thread, items is undefined until its replies load.
console.log(thread.messagePaginator.items);

How the first replies arrive depends on where the thread came from:

  • A thread from the thread list or client.getThreadAndHydrate() comes with its latest replies, and its list starts with them.
  • A thread from client.threads.ensure() has no replies yet. When its parent message has replies, or its channel isn't watched, it's stale. It loads when it's activated, while client.threads handles events: Thread.reload() fetches the thread and fills the list.

Thread.reload() keeps replies that failed to send. Thread and thread manager lists the thread's API.

See also