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