Skip to main content

Direct Messaging with React State

Direct messages (DMs) are one-on-one conversations separate from public channels. While they share the same real-time message streaming as channels, they have unique requirements: you need a distinct UI for the DM list, unread badge counting, active/away status indicators, and the ability to start a DM with any user without pre-joining a channel. This guide teaches you how to implement DMs cleanly in React without duplicating channel logic.

A common mistake is storing DMs in the same data structure as channels, which leads to convoluted conditional rendering and shared state that causes both to re-render together. Instead, we'll use a separate directMessages object keyed by a normalized user pair, letting each DM have its own message history, unread count, and participant metadata.

DM State Shape and Storage​

Store direct messages in a separate slice of your global state:

const directMessages = {
// Key is always sorted: userId_1:userId_2 where userId_1 < userId_2 alphabetically
'user_1:user_2': {
id: 'user_1:user_2',
participants: ['user_1', 'user_2'],
messages: [
{
id: 'dm_msg_1',
userId: 'user_1',
text: 'Hey, how are you?',
timestamp: 1717344010000,
read: true, // true if read by the recipient
readAt: 1717344015000,
},
{
id: 'dm_msg_2',
userId: 'user_2',
text: 'Good, you?',
timestamp: 1717344020000,
read: false,
readAt: null,
},
],
lastMessageAt: 1717344020000,
unreadCount: 1, // unread by current user
archivedBy: [], // users who archived this DM
mutedBy: [], // users who muted notifications
},
// ... more DMs keyed this way
};

Create a utility to normalize the DM key:

export const getDMKey = (userId1, userId2) => {
const [a, b] = [userId1, userId2].sort();
return `${a}:${b}`;
};

// Usage
const dmKey = getDMKey('user_2', 'user_1'); // 'user_1:user_2'

DM List Component with Unread Badges​

The sidebar shows all DMs, sorted by most recent, with unread indicators:

function DMList({ currentUserId }) {
const { state } = useContext(ChatContext);

const sortedDMs = useMemo(() => {
return Object.values(state.directMessages)
.filter(dm => !dm.archivedBy.includes(currentUserId))
.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
}, [state.directMessages, currentUserId]);

return (
<div style={{ borderTop: '1px solid #ddd', marginTop: '12px' }}>
<h3 style={{ padding: '8px 12px', fontSize: '12px', color: '#999' }}>
DIRECT MESSAGES
</h3>
{sortedDMs.length === 0 ? (
<div style={{ padding: '12px', fontSize: '13px', color: '#999' }}>
No conversations yet
</div>
) : (
sortedDMs.map((dm) => {
const otherUserId = dm.participants.find(uid => uid !== currentUserId);
const unread = dm.unreadCount > 0;

return (
<DMListItem
key={dm.id}
dm={dm}
otherUserId={otherUserId}
isUnread={unread}
/>
);
})
)}
</div>
);
}

function DMListItem({ dm, otherUserId, isUnread }) {
const { state } = useContext(ChatContext);
const navigate = useNavigate();
const otherUser = state.users[otherUserId];

const lastMessage = dm.messages[dm.messages.length - 1];
const lastText = lastMessage
? lastMessage.text.slice(0, 30) + (lastMessage.text.length > 30 ? '...' : '')
: '';

return (
<button
onClick={() => navigate(`/chat/dm/${otherUserId}`)}
style={{
width: '100%',
padding: '8px 12px',
textAlign: 'left',
border: 'none',
backgroundColor: isUnread ? '#f0f0f0' : 'transparent',
cursor: 'pointer',
borderBottom: '1px solid #eee',
}}
>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<img
src={otherUser?.avatar}
alt={otherUser?.name}
style={{ width: '32px', height: '32px', borderRadius: '50%' }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: isUnread ? '600' : '400',
fontSize: '14px',
marginBottom: '2px',
}}
>
{otherUser?.name}
</div>
<div
style={{
fontSize: '12px',
color: '#888',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{lastText}
</div>
</div>
{isUnread && (
<span
style={{
display: 'inline-block',
width: '20px',
height: '20px',
backgroundColor: '#007bff',
color: '#fff',
borderRadius: '50%',
fontSize: '11px',
fontWeight: 'bold',
textAlign: 'center',
lineHeight: '20px',
}}
>
{dm.unreadCount}
</span>
)}
</div>
</button>
);
}

Direct Message Window​

The active DM view looks similar to a channel but without the member roster button:

function DirectMessage({ userId }) {
const { state, dispatch } = useContext(ChatContext);
const currentUserId = state.currentUser.id;
const dmKey = getDMKey(currentUserId, userId);
const dm = state.directMessages[dmKey];
const [inputValue, setInputValue] = useState('');
const listRef = useRef(null);

if (!dm) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<p>Conversation not found. Start typing to open it.</p>
</div>
);
}

// Mark messages as read when component mounts
useEffect(() => {
const unreadMessages = dm.messages.filter(
m => m.userId !== currentUserId && !m.read
);

if (unreadMessages.length > 0) {
dispatch({
type: 'MARK_DM_READ',
payload: { dmKey, messageIds: unreadMessages.map(m => m.id) },
});
}
}, [dm.id, currentUserId, dispatch]);

const handleSend = () => {
if (!inputValue.trim()) return;

const message = {
id: `dm_${Date.now()}_${Math.random()}`,
userId: currentUserId,
text: inputValue,
timestamp: Date.now(),
read: false,
readAt: null,
};

dispatch({
type: 'ADD_DM_MESSAGE',
payload: { dmKey, message },
});

setInputValue('');
};

const otherUserId = dm.participants.find(uid => uid !== currentUserId);
const otherUser = state.users[otherUserId];

return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid #ddd' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<img
src={otherUser?.avatar}
alt={otherUser?.name}
style={{ width: '32px', height: '32px', borderRadius: '50%' }}
/>
<div>
<h2 style={{ margin: 0, fontSize: '16px' }}>
{otherUser?.name}
</h2>
<div style={{ fontSize: '12px', color: '#888' }}>
{otherUser?.status === 'online' ? (
<span style={{ color: '#28a745' }}>● Online</span>
) : (
`Last seen ${Math.floor(
(Date.now() - otherUser?.lastSeen) / 60000
)} min ago`
)}
</div>
</div>
</div>
</div>

<MessageList
ref={listRef}
messages={dm.messages}
currentUserId={currentUserId}
userProfiles={state.users}
onReactionClick={(messageId, emoji) => {
dispatch({
type: 'TOGGLE_DM_REACTION',
payload: { dmKey, messageId, emoji },
});
}}
/>

<div style={{ padding: '12px', borderTop: '1px solid #ddd' }}>
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Type a message..."
style={{
width: '100%',
padding: '8px',
fontSize: '14px',
borderRadius: '4px',
border: '1px solid #ddd',
fontFamily: 'inherit',
resize: 'none',
maxHeight: '100px',
}}
rows={3}
/>
<button
onClick={handleSend}
disabled={!inputValue.trim()}
style={{
marginTop: '8px',
padding: '6px 16px',
fontSize: '14px',
backgroundColor: inputValue.trim() ? '#007bff' : '#ccc',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: inputValue.trim() ? 'pointer' : 'default',
}}
>
Send
</button>
</div>
</div>
);
}

Starting a New DM​

Allow users to start a DM with anyone without pre-creating it:

function StartDMButton({ targetUserId }) {
const { state, dispatch } = useContext(ChatContext);
const currentUserId = state.currentUser.id;
const navigate = useNavigate();

const handleClick = () => {
const dmKey = getDMKey(currentUserId, targetUserId);

// If DM doesn't exist, create it
if (!state.directMessages[dmKey]) {
dispatch({
type: 'CREATE_DM',
payload: {
dmKey,
participants: [currentUserId, targetUserId],
},
});
}

navigate(`/chat/dm/${targetUserId}`);
};

return (
<button
onClick={handleClick}
style={{
padding: '6px 12px',
fontSize: '12px',
backgroundColor: '#007bff',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Message
</button>
);
}

Key Takeaways​

  • Normalize DM keys: Always sort user IDs to ensure user_1:user_2 and user_2:user_1 map to the same DM.
  • Track read status: Include a read flag and readAt timestamp on each message to power unread badges.
  • Archive instead of delete: Mark DMs as archived rather than removing them, preserving history if the user re-opens it.
  • Separate DM and channel state: Keeps each independent and prevents cross-pollution in re-renders.

Frequently Asked Questions​

How do I show "Last seen" for offline users in a DM?​

Track lastSeen timestamp in each user's presence data. When rendering the DM header, check if the other user is online; if not, show Last seen 5 minutes ago calculated from the timestamp.

Should I persist unread DM count to localStorage?​

Yes, persist the entire DM list state including unread counts. On page load, hydrate from localStorage and sync with the server (the server is the source of truth; client state is a cache). Ignore stale unread counts from before the refresh.

How do I implement DM search or filtering?​

Filter the sorted DM list client-side by matching the other user's name or recent messages. For older message search within a DM, make an API call and append results to the message history.

Can I have group DMs with 3+ people?​

Yes, but store them separately from one-on-one DMs. Use a groupMessages object keyed by group ID, and treat them as a hybrid between channels and DMs (no join/leave, but private).

Further Reading​