Message Threads in React Chat
Threading (nested replies to individual messages) reduces noise in busy channels by letting conversations branch off without flooding the main feed. A user replies to a specific message, and the thread lives in a collapsed sidebar, keeping the channel clean. This guide shows how to model threads in your state, render them efficiently, and keep thread replies in sync with the main message feed.
The challenge is that threads are not separate entities; they're replies attached to messages. Your data model must distinguish between channel-level messages and thread replies, prevent duplicate rendering, and efficiently load thread history without fetching the entire channel's message archive.
Thread Data Model
Model threads as a property of the parent message, containing nested replies:
// Channel state with threads:
const channel = {
id: 'general',
messages: [
{
id: 'msg_1',
userId: 'user_1',
text: 'Has anyone used React 19?',
timestamp: 1717344010000,
threadId: 'thread_1', // Unique thread ID
threadReplyCount: 3, // Number of replies (for UI badge)
threadLastReplyAt: 1717344030000,
thread: {
// Nested thread data
replies: [
{
id: 'msg_1_reply_1',
userId: 'user_2',
text: 'Yes, love the new features!',
timestamp: 1717344015000,
edited: null,
reactions: { heart: ['user_1'] },
},
{
id: 'msg_1_reply_2',
userId: 'user_3',
text: 'Still on 18.2, what\'s different?',
timestamp: 1717344020000,
edited: null,
reactions: {},
},
{
id: 'msg_1_reply_3',
userId: 'user_2',
text: 'Automatic batching, useTransition, etc.',
timestamp: 1717344030000,
edited: null,
reactions: { thumbsup: ['user_1', 'user_3'] },
},
],
isLoaded: true, // Whether we fetched the full thread
isLoading: false,
},
},
// ... more channel messages
],
};
Alternatively, store threads separately for normalization:
// Normalized structure:
const state = {
channels: { /* ... */ },
threads: {
'thread_1': {
parentMessageId: 'msg_1',
channelId: 'general',
replies: [/* ... */],
},
},
};
Thread Display Component
Show a thread icon and reply count on the main message:
function ChannelMessage({ message, channelId, onThreadClick }) {
const hasThread = message.threadReplyCount > 0;
return (
<div style={{ padding: '8px 12px', marginBottom: '8px' }}>
<div style={{ fontSize: '14px', marginBottom: '4px' }}>
{message.text}
</div>
{hasThread && (
<button
onClick={() => onThreadClick(message.id)}
style={{
fontSize: '12px',
color: '#007bff',
background: 'none',
border: 'none',
cursor: 'pointer',
textDecoration: 'underline',
padding: 0,
}}
>
💬 {message.threadReplyCount} reply
{message.threadReplyCount !== 1 ? 'ies' : ''}
</button>
)}
</div>
);
}
Thread Sidebar
Display the selected thread in a side panel, separate from the main channel:
function ThreadPanel({ threadId, parentMessageId, channelId, onClose }) {
const { state, dispatch } = useContext(ChatContext);
const channel = state.channels[channelId];
const parentMessage = channel?.messages.find(m => m.id === parentMessageId);
const [replyText, setReplyText] = useState('');
if (!parentMessage) {
return null;
}
const thread = parentMessage.thread;
const handleAddReply = () => {
if (!replyText.trim()) return;
const reply = {
id: `${parentMessageId}_reply_${Date.now()}`,
userId: state.currentUser.id,
text: replyText,
timestamp: Date.now(),
edited: null,
reactions: {},
};
dispatch({
type: 'ADD_THREAD_REPLY',
payload: { channelId, parentMessageId, reply },
});
setReplyText('');
};
return (
<div
style={{
width: '350px',
borderLeft: '1px solid #ddd',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#f9f9f9',
}}
>
{/* Thread header */}
<div
style={{
padding: '12px',
borderBottom: '1px solid #ddd',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h3 style={{ margin: 0, fontSize: '14px' }}>Thread</h3>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '18px',
}}
>
×
</button>
</div>
{/* Parent message preview */}
<div style={{ padding: '12px', borderBottom: '1px solid #ddd' }}>
<div style={{ fontSize: '12px', fontWeight: 'bold', marginBottom: '4px' }}>
{state.users[parentMessage.userId]?.name}
</div>
<div style={{ fontSize: '13px', color: '#333' }}>
{parentMessage.text}
</div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
{new Date(parentMessage.timestamp).toLocaleTimeString()}
</div>
</div>
{/* Thread replies */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px' }}>
{thread?.replies && thread.replies.length > 0 ? (
thread.replies.map((reply) => {
const replyUser = state.users[reply.userId];
return (
<div key={reply.id} style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', fontWeight: 'bold' }}>
{replyUser?.name}
</div>
<div style={{ fontSize: '13px', color: '#333', marginBottom: '4px' }}>
{reply.text}
</div>
<div style={{ fontSize: '11px', color: '#999' }}>
{new Date(reply.timestamp).toLocaleTimeString()}
</div>
</div>
);
})
) : (
<div style={{ fontSize: '12px', color: '#999', textAlign: 'center' }}>
No replies yet. Start the thread!
</div>
)}
</div>
{/* Reply input */}
<div style={{ padding: '12px', borderTop: '1px solid #ddd' }}>
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAddReply();
}
}}
placeholder="Reply in thread..."
style={{
width: '100%',
padding: '8px',
fontSize: '13px',
borderRadius: '4px',
border: '1px solid #ddd',
fontFamily: 'inherit',
resize: 'none',
minHeight: '60px',
}}
/>
<button
onClick={handleAddReply}
disabled={!replyText.trim()}
style={{
marginTop: '8px',
padding: '6px 12px',
fontSize: '12px',
backgroundColor: replyText.trim() ? '#007bff' : '#ccc',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: replyText.trim() ? 'pointer' : 'default',
}}
>
Reply
</button>
</div>
</div>
);
}
Loading Thread History
Fetch thread replies from the server when the user opens a thread:
function useThread(channelId, parentMessageId) {
const { state, dispatch } = useContext(ChatContext);
const wsClientRef = useRef(null);
const [isLoading, setIsLoading] = useState(false);
const loadThread = useCallback(async () => {
setIsLoading(true);
// Request thread from server
wsClientRef.current?.send({
type: 'load_thread',
payload: { channelId, parentMessageId },
});
// Listen for thread_loaded response
// (you'd implement this in your WebSocket handler)
}, [channelId, parentMessageId]);
useEffect(() => {
const parentMessage = state.channels[channelId]?.messages.find(
m => m.id === parentMessageId
);
// Load thread if not already loaded
if (parentMessage && !parentMessage.thread?.isLoaded) {
loadThread();
}
}, [channelId, parentMessageId, state.channels]);
return { isLoading };
}
Main Layout with Thread Panel
Show the channel and thread side-by-side:
function ChatWindow({ channelId }) {
const [selectedThreadId, setSelectedThreadId] = useState(null);
const [selectedParentMessageId, setSelectedParentMessageId] = useState(null);
const handleThreadClick = (parentMessageId) => {
setSelectedParentMessageId(parentMessageId);
setSelectedThreadId(`thread_${parentMessageId}`);
};
return (
<div style={{ display: 'flex', height: '100%' }}>
{/* Main channel */}
<div style={{ flex: 1, overflow: 'hidden' }}>
<ChannelHeader channel={state.channels[channelId]} />
<MessageList
messages={state.channels[channelId].messages}
onThreadClick={handleThreadClick}
/>
<MessageInput onSend={(text) => sendMessage(channelId, text)} />
</div>
{/* Thread sidebar */}
{selectedParentMessageId && (
<ThreadPanel
threadId={selectedThreadId}
parentMessageId={selectedParentMessageId}
channelId={channelId}
onClose={() => {
setSelectedParentMessageId(null);
setSelectedThreadId(null);
}}
/>
)}
</div>
);
}
Reducer for Thread Actions
Handle thread operations in your reducer:
case 'ADD_THREAD_REPLY': {
const { channelId, parentMessageId, reply } = action.payload;
const channel = state.channels[channelId];
const msgIndex = channel.messages.findIndex(m => m.id === parentMessageId);
if (msgIndex !== -1) {
const updatedMessages = [...channel.messages];
const parentMsg = updatedMessages[msgIndex];
updatedMessages[msgIndex] = {
...parentMsg,
threadReplyCount: (parentMsg.threadReplyCount || 0) + 1,
threadLastReplyAt: reply.timestamp,
thread: {
...parentMsg.thread,
replies: [...(parentMsg.thread?.replies || []), reply],
},
};
return {
...state,
channels: {
...state.channels,
[channelId]: {
...channel,
messages: updatedMessages,
},
},
};
}
return state;
}
Key Takeaways
- Threads attach to messages: Use
message.threadIdandmessage.thread.repliesto model nested conversations. - Show thread badge on main message: Display a clickable
💬 3 repliesbutton to open the thread panel. - Thread panel is separate UI: Load and display thread history in a sidebar, keeping the main channel uncluttered.
- Lazy-load thread history: Fetch replies only when the user opens a thread.
Frequently Asked Questions
How do I handle very long threads (500+ replies)?
Paginate thread replies: show the first 50, then a "Load more" button. Use a threadCursor to track where you left off and fetch the next batch from the server.
Can threads have nested threads (replies to replies)?
Yes, but it gets complex. Limit nesting to 1 level (reply to a message, not reply to a reply) to keep the UX simple. Deeper nesting is rare in practice.
How do I keep thread count in sync when replies arrive in real time?
When a new thread reply arrives, dispatch an action that increments threadReplyCount and adds the reply to thread.replies. Update threadLastReplyAt to the new reply's timestamp.
Should I show thread activity in the channel's "unread" count?
Yes, thread replies should count toward channel unread, or have a separate "thread unreads" badge. Track unread replies per thread and update when the user opens the thread.