Custom message
Every message in a message list is rendered by Message, which creates the message's handlers and renders the MessageItemView key. The default MessageItemView changes a lot through the theme, and each of its parts has its own override key. When that isn't enough, you replace MessageItemView itself. This UI cookbook first decorates the default view, then builds a view of its own step by step, reusing the SDK's parts where they help, and ends with two ways to wrap the default view in a gesture. How overrides apply is explained in Overriding components.
Best practices
- Restyle the default view, or replace one of its parts, before replacing the whole view. A replacement has to handle every kind of message your app shows.
- Read the message and its handlers with
useMessageContext(). The SDK passesMessageItemViewno props. - Render the message's content with
MessageContent, or its text withMessageTextContainer, so Markdown, links and mentions keep working. - Attach
contextMenuAnchorRefto the bubble, and make sure a long press reachesonLongPress, or the message menu doesn't open where it should. - Declare the override object and the components in it at module scope. A message view renders once per row, so keep it shallow and subscribe it only to what it shows.
- Test the result with deleted, failed and edited messages, messages with attachments, quoted replies and polls, and messages in a thread.
Decorating the default view
Four keys render around a message's content, inside its bubble: MessageContentTopView, MessageContentBottomView, MessageContentLeadingView and MessageContentTrailingView. A fifth, MessageSpacer, renders last in the message row, beside the content and outside the bubble. They have no default, so the SDK renders nothing for them until you set one. They receive no props and read the message from MessageContext.
This one puts an "Urgent" label above the content of messages that carry a custom priority field:
import { StyleSheet, Text } from "react-native";
import { useMessageContext, useTheme } from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";
declare module "stream-chat" {
interface CustomMessageData {
priority?: "high" | "normal";
}
}
const UrgentLabel = () => {
const { message } = useMessageContext();
const {
theme: { semantics },
} = useTheme();
if (message.custom?.priority !== "high") return null;
return (
<Text style={[styles.label, { color: semantics.accentError }]}>Urgent</Text>
);
};
export const urgentOverrides: ComponentOverrides = {
MessageContentTopView: UrgentLabel,
};
const styles = StyleSheet.create({
label: {
fontSize: 12,
fontWeight: "700",
paddingHorizontal: 12,
paddingTop: 8,
},
});![]() | ![]() |
Pass urgentOverrides to a WithComponents around your channel screen. The label renders inside the bubble, so it takes the bubble's background and shape. MessageContentLeadingView and MessageContentTrailingView add a row around the content only while one of them is set. Where custom fields come from, and the declare module block that types them, is explained in Data conventions.
The other parts of the default view, such as MessageHeader, MessageFooter, MessageAuthor and MessageStatus, are override keys too. The table on Message list UI lists them.
Building a message view
The rest of this UI cookbook builds ChatMessage, a view with the sender's name above the bubble and the time, the status and the reactions below it. Each step adds one part.
Text and the message menu
Start with the text, and the long press that opens the message menu:
import { Pressable, StyleSheet, View } from "react-native";
import {
MessageTextContainer,
useMessageContext,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";
const ChatMessage = () => {
const { alignment, contextMenuAnchorRef, onLongPress } = useMessageContext();
return (
<View style={[styles.row, alignment === "right" && styles.rowRight]}>
<Pressable
onLongPress={(event) => onLongPress({ emitter: "message", event })}
>
<View ref={contextMenuAnchorRef} style={styles.bubble}>
<MessageTextContainer />
</View>
</Pressable>
</View>
);
};
export const messageOverrides: ComponentOverrides = {
MessageItemView: ChatMessage,
};
const styles = StyleSheet.create({
bubble: { borderRadius: 16, maxWidth: 280, padding: 8 },
row: { flexDirection: "row", paddingHorizontal: 8, paddingVertical: 2 },
rowRight: { justifyContent: "flex-end" },
});Pass messageOverrides to a WithComponents around Channel and Thread, or around the whole app:
Messageputs the message, its alignment and its handlers intoMessageContext, then rendersChatMessage.alignmentis"right"for the current user's messages and"left"for everyone else's, unlessforceAlignMessagesonChannelputs them all on one side.MessageTextContainerrenders the text with the SDK's Markdown rules, mentions and links.- A long press calls
onLongPress, which opens the message menu: the reactions above the message and the actions below it. The menu lines up with the view incontextMenuAnchorRef.
Every component inside a message reads MessageContext with useMessageContext(): the message, its alignment and group, its attachments sorted by kind, and the handlers for its actions. Read only what the component shows.
Avatar and name
Messages from the same user, sent one after another, form a group. groupStyles[0] says where a message sits in its group: "top", "middle", "bottom" or "single". The name goes on the first message of a group, and MessageAuthor renders the avatar on the last one, and an empty space of the same width on the others, so the bubbles of a group line up:
const ChatMessage = () => {
const { alignment, contextMenuAnchorRef, groupStyles, message, onLongPress } =
useMessageContext();
const isLeft = alignment === "left";
const firstInGroup = groupStyles[0] === "top" || groupStyles[0] === "single";
return (
<View style={[styles.row, !isLeft && styles.rowRight]}>
{isLeft ? <MessageAuthor /> : null}
<View style={[styles.column, !isLeft && styles.columnRight]}>
{isLeft && firstInGroup ? (
<Text style={styles.name}>
{message.user?.name ?? message.user?.id}
</Text>
) : null}
<Pressable
onLongPress={(event) => onLongPress({ emitter: "message", event })}
>
<View ref={contextMenuAnchorRef} style={styles.bubble}>
<MessageTextContainer />
</View>
</Pressable>
</View>
</View>
);
};The default view shows the sender's name in the footer of other users' messages, except in channels with exactly two members. Read channel.state through useStateStore if your view needs the member count, with a selector declared at module scope, as Reading state in components explains.
Time, status and edits
The footer shows when the message was sent, whether it was edited, and, on the user's own messages, its status. Server timestamps are nanosecond numbers, and convertTimestampToDate from stream-chat turns one into a Date:
const ChatMessageFooter = () => {
const { lastGroupMessage, message, showMessageStatus } = useMessageContext();
const { t } = useTranslationContext();
const createdAt = useMemo(
() => convertTimestampToDate(message.created_at),
[message.created_at],
);
// Like the default footer, only the last message of a group shows it, unless it's on its way.
if (!lastGroupMessage && message.status === "received") return null;
return (
<View style={styles.footer}>
{showMessageStatus ? <MessageStatus /> : null}
<MessageTimestamp timestamp={createdAt} />
{isEditedMessage(message) ? (
<Text style={styles.footerText}>
{t("message.edited.text", "Edited")}
</Text>
) : null}
</View>
);
};Render <ChatMessageFooter /> under the bubble.
MessageTimestampformats the date with thetimestamp.MessageTimestamptranslation key, so it follows the user's language and your date formats.showMessageStatusistrueon the user's own messages.MessageStatusshows a clock while the message is sending, then one check, two checks once another member received it, and two checks in the accent color once someone read it. The Status indicators UI cookbook changes it.isEditedMessage(message)istrueonce the text was edited.
Replies and reactions
In a channel, a message that started a thread shows a button with its reply count, which opens the thread. Inside a Thread, useThreadContext().thread is set, and the button doesn't belong there:
const { thread } = useThreadContext();
// under the bubble
<ReactionListBottom />;
{
!thread && message.reply_count ? <MessageReplies /> : null;
}ReactionListBottom renders nothing when the message has no reactions. Without a type prop it renders the clustered list, whatever reactionListType on Channel says, and tapping it opens the list of who reacted. MessageReplies calls onOpenThread from the context, which calls the onThreadSelect you passed to the message list with the message's Thread. Both read the message from context, so they need no props.
Attachments, quoted replies and polls
MessageTextContainer renders only the text. Replace it with MessageContent, which renders everything a message can hold, in the order of messageContentOrder on Channel: the quoted message, the gallery, files, a poll, other attachments, a shared location (through the MessageLocation key, which has no default) and the text. It handles taps on each part, and calls onLongPress itself on a long press, so the Pressable can go:
const {
theme: { semantics },
} = useTheme();
<View ref={contextMenuAnchorRef}>
<MessageContent
backgroundColor={
isMyMessage ? semantics.chatBgOutgoing : semantics.chatBgIncoming
}
/>
</View>;MessageContent draws the bubble with the backgroundColor you pass, and leaves it transparent without one. The theme's chatBgOutgoing and chatBgIncoming are the colors the default view uses. The four content slots from Decorating the default view render inside MessageContent, so they keep working.
Deleted and failed messages
A deleted message has type: "deleted" and keeps its place in the list. A message that failed to send has status: "failed", and one the server rejected has type: "error". Tapping one opens the message menu, with retry, edit and delete, and a message that moderation bounced opens a dialog with its options instead. MessageError is the badge the default view puts on its bubble:
if (message.type === "deleted") {
return (
<View style={[styles.row, !isLeft && styles.rowRight]}>
<Text style={styles.deleted}>
{t("message.deleted.text", "Message deleted")}
</Text>
</View>
);
}
const failed = message.type === "error" || message.status === "failed";
// next to MessageContent
{
failed ? <MessageError /> : null;
}System messages, such as "Cleo joined the channel", never reach MessageItemView. The list renders them with MessageSystem. Blocked messages go to MessageBlocked.
Complete example
import { useMemo } from "react";
import { StyleSheet, Text, View } from "react-native";
import { convertTimestampToDate } from "stream-chat";
import {
isEditedMessage,
MessageAuthor,
MessageContent,
MessageError,
MessageReplies,
MessageStatus,
MessageTimestamp,
ReactionListBottom,
useMessageContext,
useTheme,
useThreadContext,
useTranslationContext,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";
const ChatMessageFooter = () => {
const { lastGroupMessage, message, showMessageStatus } = useMessageContext();
const { t } = useTranslationContext();
const createdAt = useMemo(
() => convertTimestampToDate(message.created_at),
[message.created_at],
);
// Like the default footer, only the last message of a group shows it, unless it's on its way.
if (!lastGroupMessage && message.status === "received") return null;
return (
<View style={styles.footer}>
{showMessageStatus ? <MessageStatus /> : null}
<MessageTimestamp timestamp={createdAt} />
{isEditedMessage(message) ? (
<Text style={styles.footerText}>
{t("message.edited.text", "Edited")}
</Text>
) : null}
</View>
);
};
const ChatMessage = () => {
const { alignment, contextMenuAnchorRef, groupStyles, isMyMessage, message } =
useMessageContext();
const { thread } = useThreadContext();
const { t } = useTranslationContext();
const {
theme: { semantics },
} = useTheme();
const isLeft = alignment === "left";
const firstInGroup = groupStyles[0] === "top" || groupStyles[0] === "single";
const failed = message.type === "error" || message.status === "failed";
if (message.type === "deleted") {
return (
<View style={[styles.row, !isLeft && styles.rowRight]}>
<Text style={styles.deleted}>
{t("message.deleted.text", "Message deleted")}
</Text>
</View>
);
}
return (
<View style={[styles.row, !isLeft && styles.rowRight]}>
{isLeft ? <MessageAuthor /> : null}
<View style={[styles.column, !isLeft && styles.columnRight]}>
{isLeft && firstInGroup ? (
<Text style={styles.name}>
{message.user?.name ?? message.user?.id}
</Text>
) : null}
<View ref={contextMenuAnchorRef} style={styles.bubble}>
<MessageContent
backgroundColor={
isMyMessage ? semantics.chatBgOutgoing : semantics.chatBgIncoming
}
/>
{failed ? <MessageError /> : null}
</View>
<ReactionListBottom />
{!thread && message.reply_count ? <MessageReplies /> : null}
<ChatMessageFooter />
</View>
</View>
);
};
// Declared once, at module scope.
export const messageOverrides: ComponentOverrides = {
MessageItemView: ChatMessage,
};
const styles = StyleSheet.create({
bubble: { alignItems: "flex-start", flexDirection: "row", gap: 4 },
column: { alignItems: "flex-start", flexShrink: 1, gap: 2, maxWidth: "80%" },
columnRight: { alignItems: "flex-end" },
deleted: { fontStyle: "italic", opacity: 0.6, padding: 8 },
footer: { alignItems: "center", flexDirection: "row", gap: 4 },
footerText: { fontSize: 12, opacity: 0.6 },
name: { fontSize: 12, fontWeight: "600" },
row: {
alignItems: "flex-end",
flexDirection: "row",
gap: 8,
paddingHorizontal: 8,
paddingVertical: 2,
},
rowRight: { justifyContent: "flex-end" },
});![]() | ![]() |
Register it on a WithComponents around your channel and thread screens, or around the whole app:
<WithComponents overrides={messageOverrides}>
<Channel channel={channel}>
<MessageList />
<MessageComposer />
</Channel>
</WithComponents>ChatMessage doesn't render MessageHeader, so the marks the default view shows above a message are gone: "Pinned by", the reminder and "Saved For Later" labels, and "Also sent in channel". Render <MessageHeader /> above the name to keep them. The default view also quotes a message when the user swipes it to the right, or to the left in a right-to-left layout. A view of your own doesn't, unless it adds the gesture itself.
Wrapping the default view
Some changes need the default view as it is, with something around it. The SDK's MessageItemView is exported, so a replacement can render it inside a gesture or a container of its own.
Double tap to react
A double tap on a message adds a love reaction, or removes it if the user already gave one. handleReaction from MessageContext is the handler the reaction picker uses: it calls the handleReaction prop of Channel first, toggles the reaction through the channel or the thread, and is undefined on failed messages:
import { useMemo } from "react";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import {
MessageItemView,
useMessageContext,
useOwnCapabilitiesContext,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";
const DoubleTapMessage = () => {
const { handleReaction } = useMessageContext();
const { sendReaction } = useOwnCapabilitiesContext();
const doubleTap = useMemo(
() =>
Gesture.Tap()
.numberOfTaps(2)
.maxDistance(8)
.runOnJS(true)
.enabled(!!handleReaction && sendReaction)
.onEnd((_event, success) => {
if (success) void handleReaction?.("love");
}),
[handleReaction, sendReaction],
);
return (
<GestureDetector gesture={doubleTap}>
<MessageItemView />
</GestureDetector>
);
};
export const doubleTapOverrides: ComponentOverrides = {
MessageItemView: DoubleTapMessage,
};runOnJS(true) runs the callbacks on the JavaScript thread, where the handler lives. The gesture is off for users without the send-reaction capability. love is one of the default reaction types; with your own supportedReactions, use a type from your list, or the reaction is saved but no message shows it.
Swipe to delete
The default view quotes a message when the user swipes it. This one instead reveals a Delete button when the user swipes a message they can delete to the left. It uses ReanimatedSwipeable from react-native-gesture-handler, and handleDeleteMessage from useMessageActionHandlers(), which asks for confirmation and then deletes the message:
import { useCallback } from "react";
import { Pressable, StyleSheet, Text } from "react-native";
import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import {
MessageItemView,
useMessageActionHandlers,
useMessageContext,
useOwnCapabilitiesContext,
useTheme,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";
const SwipeToDeleteMessage = () => {
const { isMyMessage, message } = useMessageContext();
const { deleteAnyMessage, deleteOwnMessage } = useOwnCapabilitiesContext();
const { handleDeleteMessage } = useMessageActionHandlers();
const {
theme: { semantics },
} = useTheme();
const canDelete =
message.type !== "deleted" &&
((isMyMessage && deleteOwnMessage) || (!isMyMessage && deleteAnyMessage));
const renderRightActions = useCallback(
(
_progress: unknown,
_translation: unknown,
swipeable: SwipeableMethods,
) => (
<Pressable
accessibilityRole="button"
onPress={() => {
swipeable.close();
handleDeleteMessage();
}}
style={[styles.action, { backgroundColor: semantics.accentError }]}
>
<Text style={styles.actionText}>Delete</Text>
</Pressable>
),
[handleDeleteMessage, semantics.accentError],
);
if (!canDelete) return <MessageItemView />;
return (
<ReanimatedSwipeable
friction={2}
overshootRight={false}
renderRightActions={renderRightActions}
>
<MessageItemView />
</ReanimatedSwipeable>
);
};
export const swipeOverrides: ComponentOverrides = {
MessageItemView: SwipeToDeleteMessage,
};
const styles = StyleSheet.create({
action: { justifyContent: "center", paddingHorizontal: 20 },
actionText: { color: "white", fontWeight: "600" },
});Pass enableSwipeToReply={false} to Channel and Thread with this override, so the default view's own swipe doesn't compete with the new one. useMessageActionHandlers() returns the handlers Message built for this message, so calling it in every row costs nothing; the other handlers it returns are listed in Actions. The check mirrors the one the SDK uses for its own delete action.
Read more
- Overriding components: how
WithComponentsmerges overrides, and why they're read once. - Message list UI: what the list renders around each message.
- Message UI components: every part of the default view and its props.
- Incoming and outgoing styles: restyling the user's own messages without replacing the view.
- Pin indicator: restyling or replacing the "Pinned by" line above a pinned message.
- System message: restyling or replacing
MessageSystem, which renders system messages in place of the message view.



