Part 2 - Message List
In the second part of the tutorial, we're going to add the Virtuoso Message List component to our application and bind it to data generated from the utilities in the previous step. Install the NPM package:
npm i @virtuoso.dev/message-list
The tutorial will not explicitly list the import statements for the components and classes used in the code snippets. Most likely, your IDE will automatically add the necessary imports when you paste the code. If not, you can add them manually.
Load a channel with some simple messages
In this step, we will modify the homepage of the application to display message
list with several chat messages in the VirtuosoMessageList
. First, let's add
the state: Replace the contents of src/App.tsx
with the following code:
// The channel data type defines the `data` prop passed to the VirtuosoMessageList component.
// The prop accepts an object defining the data to display and optional instructions on how the scroll location should change.
type ChannelData = DataWithScrollModifier<ChatMessage> | null
type ChannelsData = Record<string, ChannelData>
// use this shape to start channels at the bottom of the list
const InitialDataScrollModifier: ScrollModifier = {
type: 'item-location',
location: {
index: 'LAST',
align: 'end',
},
purgeItemSizes: true,
}
// This function is used to generate key properties for the message list items based on the data rendered.
// use a stable identifier to avoid unnecessary re-mounts when the message list data changes.
const computeItemKey: VirtuosoMessageListProps<ChatMessage, null>['computeItemKey'] = ({ data }) => {
if (data.id !== null) {
return data.id
}
return `l-${data.localId}`
}
function App() {
// we will start with a single 'general' channel
const [channelsData, setChannelsData] = useState<ChannelsData>(() => ({
general: null,
}))
const [currentChannel, setCurrentChannel] = useState<string>('general')
const messageListData = useMemo(() => {
return channelsData[currentChannel] ?? null
}, [channelsData, currentChannel])
const setMessageListData = useCallback(
(cb: (current: ChannelData) => ChannelData) => {
setChannelsData((current) => {
return {
...current,
[currentChannel]: cb(current[currentChannel] ?? null),
}
})
},
[currentChannel]
)
const [currentUser, otherUser] = useMemo(() => {
return [createUser(1), createUser(2)]
}, [])
// this loads the initial data in the channel, simulating a network request
useEffect(() => {
if (messageListData === null || messageListData.data === null) {
setTimeout(() => {
setMessageListData((current) => {
if (current?.data?.length) {
return current
}
const messages = Array.from({ length: 120 }, (_, i) => createMessage(i % 3 === 0 ? currentUser : otherUser))
return {
data: messages,
scrollModifier: InitialDataScrollModifier,
}
})
}, 500)
}
}, [currentUser, otherUser, setMessageListData, messageListData])
return <div>Coming soon</div>
}
Initially, we make a single-channel interface; In the next steps of the tutorial, we will add the ability to switch between channels.
Virtuoso Message List
Now we will add the VirtuosoMessageList
component. Replace the coming soon
div with the following code:
return (
<div>
<VirtuosoMessageListLicense licenseKey="">
<VirtuosoMessageList<ChatMessage, null> style={{ height: '80vh' }} data={messageListData} computeItemKey={computeItemKey} />
</VirtuosoMessageListLicense>
</div>
)
Let's go over each part of the code, as several key concepts are introduced here:
The message list data with scroll modifier
The messageListData
is the data that the component will render and,
optionally, a scroll modifier that will be used to control the scroll position
of the list, which lets you position the list at the end of the data, prepend
older messages when loading history, or automatically scroll to the bottom when
new messages are added. At this step, we're using the item-location
modifier,
which will position the list at the end of the data when the data is loaded.
The License wrapper
The Message List component is a commercial product and requires a valid license key to work in production. For now, we'll leave the key empty, but remember to purchase add a valid key before you deploy the application to production.
The message list generic parameters
The VirtuosoMessageList
component has two generic type parameters. The first
one is the type of the data items, and the second one is the type of the
additional context
prop you can pass to the component. This context lets you
pass state updates into the component items and its custom components (headers
and footers) without re-defining the components themselves. In this case, we're
using null
, as we're not passing any additional context. We will cover this
feature in more detail in the next parts of the tutorial.
The style
prop
The Message List needs a height to render correctly. We're setting the
height to 80vh
to have a full-screen chat interface with a bit of space at the
bottom. In your real world application, you might put the component into a flex
box layout or use other methods to control the component's height - this works
as well.
The computeItemKey
prop
The computeItemKey
prop is used to compute a unique key for each item in the
list. We're using the message ids as keys. Eventually, when we implement
optimistic rendering, we will work with local, temporary messages that don't
have an id. That's why we're also providing a key using the localId
. The
computeItemKey
prop is a function that takes an item and returns a string. The
function should be stable - it should return the same key for the same item on
each render.
Initial data loading
The useEffect
hook is used to load the initial data when the component mounts.
We're using a setTimeout
to simulate a network request. In a real-world
application, you are likely going to use a library like react-query
or swr
for data fetching.
If everything works as expected, after a short delay (caused by the setTimeout
network simulations) you should see a plain-looking list of twenty Item N
elements rendered. This is a good start, but we need to address two issues:
- A blank loading screen is not a good user experience.
- The messages don't look like chat messages.
Displaying the messages
We're going to build a custom message item component to render the chat messages
named ItemContent
. The item will display alternating messages, depending on
who's sent them. For this, it has to distinguish between the current user and
other users - we are going to utilize the context prop for this. Let's add some
utility types:
// we will use this context to pass the current user to the message list item
interface MessageListContext {
currentUser: ChatUser
}
// a shorthand for the message list props
type MessageListProps = VirtuosoMessageListProps<ChatMessage, MessageListContext>
const ItemContent: MessageListProps['ItemContent'] = ({ data: message, context }) => {
const ownMessage = context.currentUser === message.user
return (
<div
style={{
display: 'flex',
gap: '1rem',
paddingBottom: '2rem',
flexDirection: ownMessage ? 'row-reverse' : 'row',
}}
>
<img
src={message.user.avatar}
style={{
borderRadius: '100%',
width: 30,
height: 30,
border: '1px solid #ccc',
}}
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
maxWidth: '50%',
}}
>
<div
style={{
background: ownMessage ? '#3A5BC7' : '#F0F0F3',
color: ownMessage ? 'white' : 'black',
borderRadius: '1rem',
padding: '1rem',
...(ownMessage ? { borderTopRightRadius: '0' } : { borderTopLeftRadius: 'auto' }),
}}
>
{message.message}
</div>
{!message.delivered && <div style={{ textAlign: 'right' }}>Delivering...</div>}
</div>
</div>
)
}
Now, let's pass ItemContent
to the VirtuosoMessageList
component and the
currentUser
to the context:
return (
<div>
<VirtuosoMessageListLicense licenseKey="">
<VirtuosoMessageList<ChatMessage, MessageListContext>
style={{ height: '80vh' }}
context={{ currentUser }}
ItemContent={ItemContent}
data={messageListData}
computeItemKey={computeItemKey}
/>
</VirtuosoMessageListLicense>
</div>
)
Showing a loading indicator
The message list accepts an EmptyPlaceholder
prop that lets you define a custom
component to render when the no data is present. Let's use it add a simple loading
indicator to the component.
const EmptyPlaceholder: VirtuosoProps['EmptyPlaceholder'] = ({ context }) => {
return <div>{!context.channel.loaded ? 'Loading...' : 'Empty'}</div>
}
//.... Add the prop to the component
return (
<div>
<VirtuosoMessageList<ChatMessage, MessageListContext>
style={{ height: '80vh' }}
context={{ currentUser }}
ItemContent={ItemContent}
EmptyPlaceholder={EmptyPlaceholder}
data={messageListData}
computeItemKey={computeItemKey}
/>
</div>
)
The custom components should be placed outside of the body of the page component - in a separate file or at the module level. This way, you can easily re-use them in different parts of the application. Also, they won't be re-defined on each render, which causes performance issues.
If necessary, you can also extend the context with additional data to distinguish between an empty state and a loading state, and then change the content of the EmptyPlaceholder
accordingly.