Pinned messages
Pinning keeps an important message easy to find. This page covers how a message gets pinned, how the channel keeps its list of pinned messages, and the components that show them.
What users get
The user opens the menu on a message and picks Pin. A line above the message now reads "Pinned by" and the user's name, or "Pinned by You" on their own screen. Picking Unpin removes it.
Channel details, from stream-chat-react/channel-detail, have a Pinned messages section. It lists the channel's pinned messages with their author and the date they were sent, and a search field filters them by text. Clicking one jumps the channel's message list to that message.
How it works across the layers
A pin is part of the message. A pinned message has pinned: true, pinned_at, pinned_by and, when the pin expires, pin_expires.
- State.
client.pinMessageandclient.unpinMessagechange the message on the server. Themessage.updatedevent then brings the change to every client watching the channel, and each channel keeps its pinned messages inchannel.pinnedMessagesPaginator. - UI.
MessageUIrendersPinIndicatorabove any message withmessage.pinned, and adds the classstr-chat__message--pinnedto it. The Pin action in the message menu pins and unpins.PinnedMessagesViewis the Pinned messages section ofChannelDetail. - Layout. Selecting a pinned message in channel details calls
openChannelfromuseWorkspaceNavigation(), so withChatViewthe channel opens in its slot, and with aWorkspaceNavigationProviderof your own, youropenChanneldecides. Then it jumps the channel's message list to the message and closes the details.
The pinned list
channel.pinnedMessagesPaginator holds the channel's pinned messages, oldest pin first. It's a paginator like the others in the state layer: its state store has items, isLoading, hasMoreTail, hasMoreHead and lastQueryError. How pagination works explains these fields.
Example
Take a channel with 25 pinned messages:
- When the channel is queried or watched, the response carries a page of its pinned messages. They become the first page of the list.
PinnedMessagePaginator.toTail()loads the next page of earlier pins from the pinned messages endpoint.- Bob pins another message. The
message.updatedevent arrives, the message now haspinned: true, and the list adds it. When he unpins it, the nextmessage.updatedremoves it. - A pinned message that is edited, reacted to or deleted changes in the list too, because the list and the message list share each message.
Thread replies stay out of the list: the channel only adds messages from its main list. Truncating the channel also clears the pins from before the cutoff.
Pinning and unpinning
Pass the message, or an object with its id:
// Pinned until someone unpins it
await client.pinMessage(message);
// pin_expires takes a Date or an RFC 3339 string: this pin expires in an hour
await client.pinMessage(message, {
pin_expires: new Date(Date.now() + 60 * 60 * 1000),
});
await client.pinMessage(
{ id: messageId },
{ pin_expires: "2026-12-31T23:00:00Z" },
);
await client.unpinMessage(message);Neither call changes local state. The message and the pinned list update when message.updated arrives. The Pin action in the UI does more: it updates the message on screen at once and puts it back if the request fails.
Turning it on or off
Pinning is always available. The server decides who may pin through the pin-message capability, which comes from the channel type's permissions. See Permissions reference.
The default Pin action doesn't check that capability: every user sees it on channel messages, and the server rejects the request from a user who can't pin. To hide it from those users, filter the action set by the capability:
import { useMemo } from "react";
import type { ChannelStateData } from "stream-chat";
import {
MessageActions,
defaultMessageActionSet,
useChannel,
useStateStore,
} from "stream-chat-react";
const capabilitiesSelector = ({ ownCapabilities }: ChannelStateData) => ({
ownCapabilities,
});
export const MessageActionsWithPinCheck = () => {
const channel = useChannel();
const { ownCapabilities } = useStateStore(
channel.state,
capabilitiesSelector,
);
const canPin = ownCapabilities.includes("pin-message");
const messageActionSet = useMemo(
() =>
canPin
? defaultMessageActionSet
: defaultMessageActionSet.filter(
(action) =>
action.placement === "quick-dropdown-toggle" ||
action.type !== "pin",
),
[canPin],
);
return <MessageActions messageActionSet={messageActionSet} />;
};Register it with <WithComponents overrides={{ MessageActions: MessageActionsWithPinCheck }}>, as Message actions shows.
Customizing
The pin indicator
Replace PinIndicator through WithComponents. It receives the message as a prop:
import type { Channel as ChannelType } from "stream-chat";
import {
Channel,
WithComponents,
type PinIndicatorProps,
} from "stream-chat-react";
const CustomPinIndicator = ({ message }: PinIndicatorProps) => {
if (!message?.pinned) return null;
return (
<div className="custom-pin-indicator">
📌 {message.pinned_by?.name ?? "Someone"} pinned this
</div>
);
};
export const ChannelWithCustomPins = ({
channel,
}: {
channel: ChannelType;
}) => (
<WithComponents overrides={{ PinIndicator: CustomPinIndicator }}>
<Channel channel={channel}>{/* MessageList, MessageComposer */}</Channel>
</WithComponents>
);The Pin indicator recipe has a CSS-only version too.
Your own pinned list
To show pins outside channel details, for example in a bar above the message list, read channel.pinnedMessagesPaginator.state. Place this component inside Channel:
import type { LocalMessage, PaginatorState } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react";
const pinnedListSelector = ({
hasMoreTail,
isLoading,
items,
}: PaginatorState<LocalMessage>) => ({ hasMoreTail, isLoading, items });
export const PinnedBar = () => {
const channel = useChannel();
const {
hasMoreTail,
isLoading,
items = [],
} = useStateStore(channel.pinnedMessagesPaginator.state, pinnedListSelector);
if (!items.length) return null;
return (
<div className="pinned-bar">
{items.map((message) => (
<button
key={message.id}
onClick={() =>
void channel.messagePaginator.jumpToMessage(message.id)
}
>
{message.user?.name}: {message.text}
</button>
))}
{hasMoreTail && (
<button
disabled={isLoading}
onClick={() => void channel.pinnedMessagesPaginator.toTail()}
>
Earlier pins
</button>
)}
</div>
);
};The list keeps itself current, so the bar re-renders when someone pins or unpins a message. Reading state in components explains the selector rules this sample follows. Message list UI covers what the list shows after a jump.
The Pinned messages section
PinnedMessagesView loads its list through a message search, 30 messages per page, filtered to the channel's pinned messages. It runs the search only while channel.pinnedMessagesPaginator holds at least one pin. It takes a searchSource prop to use a search source of your own. To change the sections of channel details, see Channel details and members.
Reference
- Pinned messages paginator: the state, loading methods and what updates the list.
- Channel Detail Views:
PinnedMessagesViewand its props. - ComponentContext: the
PinIndicatoroverride key. - Message hooks:
usePinHandler, the handler behind the Pin action. - Pinned messages in the JavaScript client: the server API, including pin expiry.