Skip to main content
The MessageList component renders a virtualized, scrollable list of messages using react-virtuoso. It automatically groups messages by date, shows avatars for incoming messages, and displays typing indicators at the bottom.

Location

components/chat/message-list.tsx

Props

chatId
string
required
Unique identifier for the chat conversation
messages
Message[]
required
Array of message objects to display
contactInitials
string
required
Initials to display in contact avatars
profileId
string
required
ID of the current user (to determine incoming vs outgoing messages)
isTyping
boolean
Whether to show the typing indicator
typingLabel
string
Custom label for the typing indicator (defaults to “typing”)
onMediaPreview
(media: MediaAttachment) => void
Callback when a user clicks on a media attachment

Features

Virtualization with react-virtuoso

The component uses react-virtuoso for efficient rendering of long message histories. Only visible messages are rendered in the DOM, enabling smooth scrolling even with thousands of messages.
components/chat/message-list.tsx
<Virtuoso
  className="flex-1"
  style={{ height: "100%" }}
  data={timeline}
  initialTopMostItemIndex={virtuosoInitialIndex}
  followOutput="smooth"
  alignToBottom
  itemContent={(index, item) => {
    // Render message or divider
  }}
/>

Date dividers

Messages are automatically grouped by date with divider labels:
components/chat/message-list.tsx
if (!previousMessage || !isSameDay(parseISO(previousMessage.createdAt), currentDate)) {
  items.push({
    kind: "divider",
    id: `divider-${message.id}`,
    label: format(currentDate, "EEEE, MMM d"),
  })
}
Example output: “Monday, Mar 3”

Timeline building

The buildTimeline function creates a timeline of items including both messages and date dividers:
components/chat/message-list.tsx
type TimelineItem =
  | { kind: "divider"; id: string; label: string }
  | { kind: "message"; message: Message; showAvatar: boolean }

Avatar display logic

Avatars are shown for incoming messages when the previous message was outgoing (creating visual grouping by sender):
components/chat/message-list.tsx
const showAvatar =
  message.authorId !== profileId && 
  (!previousMessage || previousMessage.authorId === profileId)

Automatic scroll to bottom

When the chat changes, the list automatically scrolls to the most recent message:
components/chat/message-list.tsx
if (previousChatRef.current !== chatId) {
  previousChatRef.current = chatId
  initialIndexRef.current = timeline.length ? timeline.length - 1 : null
}
The component includes a custom footer that displays a typing indicator when isTyping is true:
components/chat/message-list.tsx
components={{
  Footer: () => (
    <div className="pb-3 pt-1">
      {isTyping ? (
        <div className="px-4">
          <TypingIndicator label={typingLabel} />
        </div>
      ) : (
        <span className="block h-2" />
      )}
    </div>
  ),
}}

Usage

import { MessageList } from "@/components/chat/message-list"

function ChatPanel({ chat, contact, messages, profileId }) {
  const [mediaPreview, setMediaPreview] = useState(null)
  const isTyping = useTypingIndicator(chat.id)
  
  return (
    <MessageList
      chatId={chat.id}
      messages={messages}
      contactInitials={getInitials(contact.name)}
      profileId={profileId}
      isTyping={isTyping}
      typingLabel={`${contact.name} is typing`}
      onMediaPreview={setMediaPreview}
    />
  )
}

Performance considerations

  • Virtualization: Only renders visible items, making it efficient for thousands of messages
  • Memoization: The timeline is memoized with useMemo to avoid rebuilding on every render
  • Mounted check: Uses isMounted state to prevent hydration mismatches in SSR

Build docs developers (and LLMs) love