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

Custom message

Every message in a message list is rendered by the MessageUI component. The default one is easy to restyle with component variables, and each of its parts has its own override key. When that isn't enough, you replace MessageUI itself. This recipe builds a custom one step by step, reusing the SDK's parts where they help. How the override works is explained in Overriding components.

Best practices

  • Restyle the default MessageUI before replacing it. A replacement has to handle every kind of message your app shows.
  • Read the message and its handlers with useMessageContext(). A custom MessageUI receives no props.
  • Render the text with MessageText, so Markdown, links and mentions keep working.
  • Show only the actions the user may take. useUserRole(message) reads the user's capabilities in the channel.
  • Test the result with deleted, failed and edited messages, messages with attachments, and messages in a thread.

Message text and avatars

Start with the simplest message UI: the raw text.

src/CustomMessageUi.tsx
import { useMessageContext } from "stream-chat-react";

export const CustomMessageUi = () => {
  const { message } = useMessageContext();

  return <div data-message-id={message.id}>{message.text}</div>;
};
Note:

The message UI and every component inside it read MessageContext with useMessageContext(): the message, the handlers for its actions, and the read and delivery state.

Register it on the MessageUI key with WithComponents. Every message list below it, in the channel and in its threads, then renders messages with CustomMessageUi:

src/ChannelWithCustomMessages.tsx
import type { Channel as StreamChannel } from "stream-chat";
import {
  Channel,
  ChannelHeader,
  MessageComposer,
  MessageList,
  WithComponents,
} from "stream-chat-react";
import { CustomMessageUi } from "./CustomMessageUi";

// Declared once, so WithComponents gets the same object on every render.
const overrides = { MessageUI: CustomMessageUi };

export const ChannelWithCustomMessages = ({
  channel,
}: {
  channel: StreamChannel;
}) => (
  <WithComponents overrides={overrides}>
    <Channel channel={channel}>
      <ChannelHeader />
      <MessageList />
      <MessageComposer />
    </Channel>
  </WithComponents>
);
Messages rendered as plain text

All messages now sit on one side, and you can't tell who sent them. isMyMessage() tells the user's own messages apart, and message.user holds the sender. Put the user's messages on the right and everyone else's on the left. The styles go in CustomMessageUi.css, imported from CustomMessageUi.tsx with import "./CustomMessageUi.css";:

src/CustomMessageUi.tsx
import { useMessageContext } from "stream-chat-react";

export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      <strong className="custom-message-ui__name">
        {message.user?.name || message.user?.id}
      </strong>
      <span>{message.text}</span>
    </div>
  );
};
Messages with the sender's name, on both sides

This already reads better. Next, replace the name with the SDK's Avatar, which shows the user's image, or their initials when they have none:

src/CustomMessageUi.tsx
import { Avatar, useMessageContext } from "stream-chat-react";

export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      <Avatar
        imageUrl={message.user?.image}
        size="sm"
        userName={message.user?.name || message.user?.id}
      />
      <span className="custom-message-ui__text">{message.text}</span>
    </div>
  );
};
Messages with avatars

The text is still plain: links, mentions and Markdown show as typed. MessageText renders the message's text through renderText, which turns them into links, mention highlights and formatted text. It reads the message from MessageContext, so it needs no props:

src/CustomMessageUi.tsx
import { Avatar, MessageText, useMessageContext } from "stream-chat-react";

export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      <Avatar
        imageUrl={message.user?.image}
        size="sm"
        userName={message.user?.name || message.user?.id}
      />
      <MessageText />
    </div>
  );
};
Messages with formatted text and highlighted mentions
Note:

A mention is highlighted but does nothing when clicked. Mentions shows how to handle clicks on it.

Metadata

The messages still lack context. Add the time the message was sent, an "Edited" mark, and whether it was sent, delivered or read.

The server's timestamps are nanosecond numbers, so convert them with convertTimestampToDate before formatting; Data conventions explains why. MessageContext gives the user's own message deliveredTo and readBy, the users the message reached and the users who read it. By default only the latest own message that was delivered or read gets them; returnAllReadData on the list gives them to every own message:

src/CustomMessageUi.tsx
import type { LocalMessage, UserResponse } from "stream-chat";
import { convertTimestampToDate } from "stream-chat";
import {
  Avatar,
  MessageText,
  useChatContext,
  useMessageContext,
  useThreadContext,
} from "stream-chat-react";

const statusIconMap = {
  delivered: "✅",
  read: "👁️",
  sending: "🛫",
  sent: "✉️",
  unknown: "❓",
};

type MessageStatusParams = {
  deliveredTo: UserResponse[];
  message: LocalMessage;
  ownUserId: string;
  readBy: UserResponse[];
  threadList: boolean;
};

const getMessageStatus = ({
  deliveredTo,
  message,
  ownUserId,
  readBy,
  threadList,
}: MessageStatusParams): keyof typeof statusIconMap => {
  const [firstReader] = readBy;
  const [firstDeliveredUser] = deliveredTo;

  const justReadByMe = readBy.length === 1 && firstReader?.id === ownUserId;
  const read = readBy.length > 0 && !justReadByMe && !threadList;

  const deliveredOnlyToMe =
    deliveredTo.length === 1 && firstDeliveredUser?.id === ownUserId;
  const delivered =
    deliveredTo.length > 0 && !deliveredOnlyToMe && !read && !threadList;

  const sent =
    message.status === "received" && !delivered && !read && !threadList;
  const sending = message.status === "sending";

  if (read) return "read";
  if (delivered) return "delivered";
  if (sent) return "sent";
  if (sending) return "sending";
  return "unknown";
};

const CustomMessageUiMetadata = () => {
  const { deliveredTo = [], message, readBy = [] } = useMessageContext();
  const { client } = useChatContext();
  const threadList = !!useThreadContext();

  const createdAt = convertTimestampToDate(message.created_at);
  const editedAt = convertTimestampToDate(message.message_text_updated_at);
  const status = getMessageStatus({
    deliveredTo,
    message,
    ownUserId: client.user?.id ?? "",
    readBy,
    threadList,
  });

  return (
    <div className="custom-message-ui__metadata">
      <div className="custom-message-ui__metadata-created-at">
        {createdAt?.toLocaleString()}
      </div>
      <div className="custom-message-ui__metadata-read-status">
        {statusIconMap[status]}
      </div>
      {editedAt && (
        <div
          className="custom-message-ui__metadata-edited-status"
          title={editedAt.toLocaleString()}
        >
          Edited
        </div>
      )}
    </div>
  );
};

export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      <div className="custom-message-ui__body">
        <Avatar
          imageUrl={message.user?.image}
          size="sm"
          userName={message.user?.name || message.user?.id}
        />
        <MessageText />
      </div>
      <CustomMessageUiMetadata />
    </div>
  );
};
Messages with their time, delivery status and edited mark

The metadata now repeats under every message. MessageList groups consecutive messages from the same sender and puts a class on the list item around each message: str-chat__li--top, --middle, --bottom or --single. Show the metadata only under the last message of a group:

src/CustomMessageUi.css
.custom-message-ui__metadata {
  display: flex;
  display: none;
  font-size: x-small;
  align-items: baseline;
}

.str-chat__li--bottom .custom-message-ui__metadata,
.str-chat__li--single .custom-message-ui__metadata {
  display: flex;
}
Metadata shown only under the last message of a group
Note:

VirtualizedMessageList puts the same classes on its items, so these rules work there too. To make the same decision in code, read groupStyles from MessageContext: it holds the message's place in its group, such as ["bottom"].

The SDK's own MessageStatus and MessageTimestamp handle more cases, such as the list of readers and translated date formats, and need no props. A custom message UI can render them instead of building its own.

Message actions

The message can be read now, but not acted on. MessageContext has a handler for each action, such as handleDelete, handleFlag, handleOpenThread and handlePin. Wire some of them to buttons that show while the pointer is over the message.

Note:

The next samples show only the components they change. The whole file is in the complete example at the end.

Show each button only to users who may use it. useUserRole(message) reads the user's capabilities in the channel: canDelete, canFlag, canPin and canReply. The default actions menu hides most actions on its own, but not Pin, so the check matters most there; Actions explains what the default filter does. The SDK offers neither Pin nor Reply on a thread reply, so those buttons stay out of a thread too. useThreadContext() returns the thread when the message renders inside Thread, and undefined in a channel:

src/CustomMessageUi.tsx
const CustomMessageUiActions = () => {
  const { handleDelete, handleFlag, handleOpenThread, handlePin, message } =
    useMessageContext();
  const threadList = !!useThreadContext();
  const { canDelete, canFlag, canPin, canReply } = useUserRole(message);

  return (
    <div className="custom-message-ui__actions">
      <div className="custom-message-ui__actions-group">
        {canPin && !threadList && (
          <button
            onClick={handlePin}
            title={message.pinned ? "Unpin" : "Pin"}
            type="button"
          >
            {message.pinned ? "📍" : "📌"}
          </button>
        )}
        {canDelete && (
          <button
            onClick={() => void handleDelete()}
            title="Delete"
            type="button"
          >
            🗑️
          </button>
        )}
        {canReply && !threadList && (
          <button onClick={handleOpenThread} title="Open thread" type="button">
            ↩️
          </button>
        )}
        {canFlag && (
          <button onClick={handleFlag} title="Flag message" type="button">
            🚩
          </button>
        )}
      </div>
    </div>
  );
};

export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      <div className="custom-message-ui__body">
        <Avatar
          imageUrl={message.user?.image}
          size="sm"
          userName={message.user?.name || message.user?.id}
        />
        <MessageText />
      </div>
      <CustomMessageUiMetadata />
      <CustomMessageUiActions />
    </div>
  );
};

The new rules go after the ones from the previous steps.

Action buttons above a hovered message
  • handlePin pins the message, or unpins it if it's pinned. The message shows its new state at once, and goes back if the request fails. The Pin indicator recipe changes how a pinned message looks.
  • handleDelete deletes the message. The message list shows it as deleted before the server answers.
  • handleOpenThread opens the message's thread through the layout, so with ChatView it opens in the thread slot. Without a layout it does nothing; see Navigation.
  • handleFlag flags the message for moderation.

Thread and reply count

When a message gets replies, its reply_count grows. Add a button next to the metadata that shows the count and opens the thread. A message in a thread's own list has no thread to open, so the button stays out of it:

src/CustomMessageUi.tsx
const CustomMessageUiMetadata = () => {
  const {
    deliveredTo = [],
    handleOpenThread,
    message,
    readBy = [],
  } = useMessageContext();
  const { client } = useChatContext();
  const threadList = !!useThreadContext();

  const createdAt = convertTimestampToDate(message.created_at);
  const editedAt = convertTimestampToDate(message.message_text_updated_at);
  const status = getMessageStatus({
    deliveredTo,
    message,
    ownUserId: client.user?.id ?? "",
    readBy,
    threadList,
  });

  return (
    <div className="custom-message-ui__metadata">
      <div className="custom-message-ui__metadata-created-at">
        {createdAt?.toLocaleString()}
      </div>
      <div className="custom-message-ui__metadata-read-status">
        {statusIconMap[status]}
      </div>
      {editedAt && (
        <div
          className="custom-message-ui__metadata-edited-status"
          title={editedAt.toLocaleString()}
        >
          Edited
        </div>
      )}
      {!threadList && message.reply_count > 0 && (
        <button
          className="custom-message-ui__metadata-reply-count"
          onClick={handleOpenThread}
          type="button"
        >
          <span>
            {message.reply_count}{" "}
            {message.reply_count > 1 ? "replies" : "reply"}
          </span>
        </button>
      )}
    </div>
  );
};

Deleted message

A deleted message keeps its place in the list, without its text. isMessageDeleted(message) covers every way a message can be deleted: a soft delete, a message of type deleted, and a message deleted only for the current user. Render a placeholder instead of the content:

src/CustomMessageUi.tsx
export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];
  const isDeleted = isMessageDeleted(message);

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      {isDeleted && (
        <div className="custom-message-ui__body">
          This message has been deleted...
        </div>
      )}
      {!isDeleted && (
        <>
          <div className="custom-message-ui__body">
            <Avatar
              imageUrl={message.user?.image}
              size="sm"
              userName={message.user?.name || message.user?.id}
            />
            <MessageText />
          </div>
          <CustomMessageUiMetadata />
          <CustomMessageUiActions />
        </>
      )}
    </div>
  );
};
A deleted message shown as a placeholder

Reactions

Next, add two reactions, thumbs up and thumbs down, as buttons next to the actions, and show the reactions a message has with the SDK's MessageReactions.

First define the reactions and register them as reactionOptions with WithComponents, next to the message UI. Each key is the reaction type stored on the server. MessageReactions, the reactions dialog and your own components read them from there; Reactions explains the format:

src/ChannelWithCustomMessages.tsx
import type { Channel as StreamChannel } from "stream-chat";
import type { ReactionOptions } from "stream-chat-react";
import {
  Channel,
  ChannelHeader,
  emojiToUnicode,
  MessageComposer,
  MessageList,
  WithComponents,
} from "stream-chat-react";
import { CustomMessageUi } from "./CustomMessageUi";

const reactionOptions: ReactionOptions = {
  quick: {
    "+1": {
      Component: () => <>👍</>,
      name: "Thumbs up",
      unicode: emojiToUnicode("👍"),
    },
    "-1": {
      Component: () => <>👎</>,
      name: "Thumbs down",
      unicode: emojiToUnicode("👎"),
    },
  },
};

const overrides = { MessageUI: CustomMessageUi, reactionOptions };

export const ChannelWithCustomMessages = ({
  channel,
}: {
  channel: StreamChannel;
}) => (
  <WithComponents overrides={overrides}>
    <Channel channel={channel}>
      <ChannelHeader />
      <MessageList />
      <MessageComposer />
    </Channel>
  </WithComponents>
);

Then give CustomMessageUiActions a button for each quick reaction. It reads the registered options with useComponentContext(). handleReaction(type, event) adds the reaction, or removes it if the user already reacted with it, and the message shows the change before the server answers. canReact is the send-reaction capability:

src/CustomMessageUi.tsx
const CustomMessageUiActions = () => {
  const {
    handleDelete,
    handleFlag,
    handleOpenThread,
    handlePin,
    handleReaction,
    message,
  } = useMessageContext();
  const threadList = !!useThreadContext();
  const { canDelete, canFlag, canPin, canReact, canReply } =
    useUserRole(message);
  const { reactionOptions } = useComponentContext();
  const quickReactions =
    reactionOptions && "quick" in reactionOptions ? reactionOptions.quick : {};

  return (
    <div className="custom-message-ui__actions">
      <div className="custom-message-ui__actions-group">
        {canPin && !threadList && (
          <button
            onClick={handlePin}
            title={message.pinned ? "Unpin" : "Pin"}
            type="button"
          >
            {message.pinned ? "📍" : "📌"}
          </button>
        )}
        {canDelete && (
          <button
            onClick={() => void handleDelete()}
            title="Delete"
            type="button"
          >
            🗑️
          </button>
        )}
        {canReply && !threadList && (
          <button onClick={handleOpenThread} title="Open thread" type="button">
            ↩️
          </button>
        )}
        {canFlag && (
          <button onClick={handleFlag} title="Flag message" type="button">
            🚩
          </button>
        )}
      </div>
      {canReact && (
        <div className="custom-message-ui__actions-group">
          {Object.entries(quickReactions).map(([type, { Component, name }]) => (
            <button
              key={type}
              onClick={(event) => void handleReaction(type, event)}
              title={`React with: ${name}`}
              type="button"
            >
              <Component />
            </button>
          ))}
        </div>
      )}
    </div>
  );
};

Finally, render MessageReactions under the metadata. It shows each reaction with its count, and opens the reactions dialog when clicked.

Complete example

src/CustomMessageUi.tsx
import type { LocalMessage, UserResponse } from "stream-chat";
import { convertTimestampToDate } from "stream-chat";
import {
  Avatar,
  isMessageDeleted,
  MessageReactions,
  MessageText,
  useChatContext,
  useComponentContext,
  useMessageContext,
  useThreadContext,
  useUserRole,
} from "stream-chat-react";
import "./CustomMessageUi.css";

const statusIconMap = {
  delivered: "✅",
  read: "👁️",
  sending: "🛫",
  sent: "✉️",
  unknown: "❓",
};

type MessageStatusParams = {
  deliveredTo: UserResponse[];
  message: LocalMessage;
  ownUserId: string;
  readBy: UserResponse[];
  threadList: boolean;
};

const getMessageStatus = ({
  deliveredTo,
  message,
  ownUserId,
  readBy,
  threadList,
}: MessageStatusParams): keyof typeof statusIconMap => {
  const [firstReader] = readBy;
  const [firstDeliveredUser] = deliveredTo;

  const justReadByMe = readBy.length === 1 && firstReader?.id === ownUserId;
  const read = readBy.length > 0 && !justReadByMe && !threadList;

  const deliveredOnlyToMe =
    deliveredTo.length === 1 && firstDeliveredUser?.id === ownUserId;
  const delivered =
    deliveredTo.length > 0 && !deliveredOnlyToMe && !read && !threadList;

  const sent =
    message.status === "received" && !delivered && !read && !threadList;
  const sending = message.status === "sending";

  if (read) return "read";
  if (delivered) return "delivered";
  if (sent) return "sent";
  if (sending) return "sending";
  return "unknown";
};

const CustomMessageUiActions = () => {
  const {
    handleDelete,
    handleFlag,
    handleOpenThread,
    handlePin,
    handleReaction,
    message,
  } = useMessageContext();
  const threadList = !!useThreadContext();
  const { canDelete, canFlag, canPin, canReact, canReply } =
    useUserRole(message);
  const { reactionOptions } = useComponentContext();
  const quickReactions =
    reactionOptions && "quick" in reactionOptions ? reactionOptions.quick : {};

  return (
    <div className="custom-message-ui__actions">
      <div className="custom-message-ui__actions-group">
        {canPin && !threadList && (
          <button
            onClick={handlePin}
            title={message.pinned ? "Unpin" : "Pin"}
            type="button"
          >
            {message.pinned ? "📍" : "📌"}
          </button>
        )}
        {canDelete && (
          <button
            onClick={() => void handleDelete()}
            title="Delete"
            type="button"
          >
            🗑️
          </button>
        )}
        {canReply && !threadList && (
          <button onClick={handleOpenThread} title="Open thread" type="button">
            ↩️
          </button>
        )}
        {canFlag && (
          <button onClick={handleFlag} title="Flag message" type="button">
            🚩
          </button>
        )}
      </div>
      {canReact && (
        <div className="custom-message-ui__actions-group">
          {Object.entries(quickReactions).map(([type, { Component, name }]) => (
            <button
              key={type}
              onClick={(event) => void handleReaction(type, event)}
              title={`React with: ${name}`}
              type="button"
            >
              <Component />
            </button>
          ))}
        </div>
      )}
    </div>
  );
};

const CustomMessageUiMetadata = () => {
  const {
    deliveredTo = [],
    handleOpenThread,
    message,
    readBy = [],
  } = useMessageContext();
  const { client } = useChatContext();
  const threadList = !!useThreadContext();

  const createdAt = convertTimestampToDate(message.created_at);
  const editedAt = convertTimestampToDate(message.message_text_updated_at);
  const status = getMessageStatus({
    deliveredTo,
    message,
    ownUserId: client.user?.id ?? "",
    readBy,
    threadList,
  });

  return (
    <div className="custom-message-ui__metadata">
      <div className="custom-message-ui__metadata-created-at">
        {createdAt?.toLocaleString()}
      </div>
      <div className="custom-message-ui__metadata-read-status">
        {statusIconMap[status]}
      </div>
      {editedAt && (
        <div
          className="custom-message-ui__metadata-edited-status"
          title={editedAt.toLocaleString()}
        >
          Edited
        </div>
      )}
      {!threadList && message.reply_count > 0 && (
        <button
          className="custom-message-ui__metadata-reply-count"
          onClick={handleOpenThread}
          type="button"
        >
          <span>
            {message.reply_count}{" "}
            {message.reply_count > 1 ? "replies" : "reply"}
          </span>
        </button>
      )}
    </div>
  );
};

export const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];
  const isDeleted = isMessageDeleted(message);

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      {isDeleted && (
        <div className="custom-message-ui__body">
          This message has been deleted...
        </div>
      )}
      {!isDeleted && (
        <>
          <div className="custom-message-ui__body">
            <Avatar
              imageUrl={message.user?.image}
              size="sm"
              userName={message.user?.name || message.user?.id}
            />
            <MessageText />
          </div>
          <CustomMessageUiMetadata />
          <CustomMessageUiActions />
          <MessageReactions />
        </>
      )}
    </div>
  );
};
The complete custom message UI with reactions

Attachments

This message UI shows no attachments. To show them, render the SDK's Attachment with the message's attachments, as the default MessageUI does. handleAction runs the buttons of a Giphy preview, such as Send and Shuffle:

const CustomMessageUiAttachments = () => {
  const { handleAction, message } = useMessageContext();

  if (!message.attachments?.length) return null;

  return (
    <Attachment
      actionHandler={handleAction}
      attachments={message.attachments}
    />
  );
};

Render <CustomMessageUiAttachments /> in CustomMessageUi, next to MessageText.

Attachment picks the right component for each attachment: images, videos, files, voice recordings, link previews and Giphy. Attachments and uploads explains how to change how each kind renders, and Overriding components shows a message UI that renders them.

Read more

The default MessageUI handles more than this recipe does: