Building Channel Components in React
Channels are the backbone of multi-user chat apps: persistent rooms where conversations accumulate over time and new members see the full history. Building a channel component that feels responsive, renders thousands of messages efficiently, and integrates with your global state requires careful attention to React's rendering model and message virtualization. This guide shows you how.
The main challenge is displaying a potentially massive message list (#general might have 100,000 messages) without lagging the browser. We'll use a virtualized scrolling technique where only the visible messages render to the DOM, dramatically cutting memory and CPU. We'll also design the channel component to subscribe to only its own messages and member list, ignoring changes in other channels.
Channel Data Structure and Selectors
Keep each channel's data immutable and normalized:
// In your global chat state
const channel = {
id: 'general',
name: 'General',
description: 'General discussion',
createdAt: 1717344000000,
createdBy: 'admin_1',
isPrivate: false,
members: ['user_1', 'user_2', 'user_3'], // user IDs only
messages: [
{
id: 'msg_1',
userId: 'user_1',
text: 'Hello everyone',
timestamp: 1717344010000,
edited: null, // non-null if edited
reactions: { thumbsup: ['user_2', 'user_3'], heart: ['user_1'] },
},
{
id: 'msg_2',
userId: 'user_2',
text: 'Hi there!',
timestamp: 1717344015000,
edited: null,
reactions: {},
},
],
};
Create a custom hook to select only the channel you're viewing:
function useChannel(channelId) {
const { state } = useContext(ChatContext);
return useMemo(() => {
const channel = state.channels[channelId];
if (!channel) return null;
return {
...channel,
memberProfiles: channel.members.map(userId => state.users[userId]),
};
}, [state.channels[channelId], channelId, state.users]);
}
This hook memoizes the channel object so the component re-renders only when that channel's messages or members actually change, not when other channels update.
Channel Component Structure
Here's a complete, minimalist channel component:
function Channel({ channelId }) {
const channel = useChannel(channelId);
const { state, dispatch } = useContext(ChatContext);
const [inputValue, setInputValue] = useState('');
const listRef = useRef(null);
const isAtBottom = useRef(true);
if (!channel) return <div>Channel not found</div>;
const handleSendMessage = () => {
if (!inputValue.trim()) return;
const message = {
id: `msg_${Date.now()}_${Math.random()}`,
userId: state.currentUser.id,
text: inputValue,
timestamp: Date.now(),
edited: null,
reactions: {},
};
dispatch({
type: 'ADD_MESSAGE',
payload: { channelId, message },
});
setInputValue('');
// Scroll to bottom after sending
setTimeout(() => {
if (listRef.current) {
listRef.current.scrollToItem(channel.messages.length - 1);
}
}, 0);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ChannelHeader channel={channel} />
<MessageList
ref={listRef}
messages={channel.messages}
currentUserId={state.currentUser.id}
userProfiles={state.users}
onReactionClick={(messageId, emoji) => {
dispatch({
type: 'TOGGLE_REACTION',
payload: { channelId, messageId, emoji },
});
}}
/>
<MessageInput
value={inputValue}
onChange={setInputValue}
onSend={handleSendMessage}
onTyping={() => {
dispatch({
type: 'BROADCAST_TYPING',
payload: { channelId },
});
}}
/>
</div>
);
}
Virtualized Message List
For large message lists, use react-window to render only visible items:
import { FixedSizeList } from 'react-window';
const MessageList = React.forwardRef(
({ messages, currentUserId, userProfiles, onReactionClick }, ref) => {
if (messages.length === 0) {
return (
<div style={{ padding: '20px', textAlign: 'center', color: '#888' }}>
No messages yet. Start the conversation!
</div>
);
}
const Row = ({ index, style }) => {
const message = messages[index];
const user = userProfiles[message.userId];
return (
<div style={style} key={message.id}>
<div style={{ padding: '8px 12px', borderBottom: '1px solid #eee' }}>
<div style={{ display: 'flex', gap: '8px', marginBottom: '4px' }}>
<img
src={user?.avatar}
alt={user?.name}
style={{ width: '32px', height: '32px', borderRadius: '50%' }}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 'bold', fontSize: '14px' }}>
{user?.name}
</div>
<div style={{ fontSize: '13px', color: '#666' }}>
{new Date(message.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
<div style={{ marginLeft: '40px', marginBottom: '8px' }}>
{message.text}
</div>
<div style={{ marginLeft: '40px', fontSize: '12px', color: '#999' }}>
{Object.entries(message.reactions).map(([emoji, userIds]) => (
<button
key={emoji}
onClick={() => onReactionClick(message.id, emoji)}
style={{
marginRight: '8px',
padding: '2px 6px',
fontSize: '12px',
backgroundColor:
userIds.includes(currentUserId) ? '#e0e0e0' : '#f5f5f5',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{emoji} {userIds.length}
</button>
))}
</div>
</div>
</div>
);
};
return (
<FixedSizeList
ref={ref}
height={600}
itemCount={messages.length}
itemSize={100}
width="100%"
>
{Row}
</FixedSizeList>
);
}
);
MessageList.displayName = 'MessageList';
Channel Header with Member Roster
Display channel metadata and active members:
function ChannelHeader({ channel }) {
const [showMembers, setShowMembers] = useState(false);
return (
<div style={{ padding: '12px 16px', borderBottom: '1px solid #ddd' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<h2 style={{ margin: '0 0 4px 0', fontSize: '18px' }}>
# {channel.name}
</h2>
<p style={{ margin: 0, fontSize: '12px', color: '#888' }}>
{channel.description}
</p>
</div>
<button
onClick={() => setShowMembers(!showMembers)}
style={{
padding: '6px 12px',
fontSize: '12px',
cursor: 'pointer',
backgroundColor: '#f0f0f0',
border: '1px solid #ccc',
borderRadius: '4px',
}}
>
Members ({channel.members.length})
</button>
</div>
{showMembers && (
<div
style={{
marginTop: '12px',
padding: '8px',
backgroundColor: '#f9f9f9',
borderRadius: '4px',
}}
>
{channel.members.map((userId) => (
<div key={userId} style={{ fontSize: '13px', padding: '4px 0' }}>
{userId}
</div>
))}
</div>
)}
</div>
);
}
Message Input Component
The input field should broadcast typing, handle multi-line input, and support mentions:
function MessageInput({ value, onChange, onSend, onTyping }) {
const [showMentions, setShowMentions] = useState(false);
const handleChange = (e) => {
onChange(e.target.value);
if (!value.trim()) onTyping(); // Broadcast that user started typing
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
};
return (
<div style={{ padding: '12px', borderTop: '1px solid #ddd' }}>
<textarea
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Shift+Enter for new line)"
style={{
width: '100%',
padding: '8px',
fontSize: '14px',
borderRadius: '4px',
border: '1px solid #ddd',
fontFamily: 'inherit',
resize: 'none',
maxHeight: '100px',
}}
rows={3}
/>
<button
onClick={onSend}
disabled={!value.trim()}
style={{
marginTop: '8px',
padding: '6px 16px',
fontSize: '14px',
backgroundColor: value.trim() ? '#007bff' : '#ccc',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: value.trim() ? 'pointer' : 'default',
}}
>
Send
</button>
</div>
);
}
Key Takeaways
- Virtualized lists scale to thousands of messages:
react-windowrenders only the visible viewport. - Memoized selectors prevent unnecessary re-renders: Changes in other channels don't touch your active channel component.
- Immutable message IDs prevent duplicates: Use UUIDs or server-assigned IDs, never timestamps alone.
- Local input state avoids global chatter: Only dispatch the message on send, not on every keystroke.
Frequently Asked Questions
How do I handle very large channels with 100k+ messages?
Paginate messages: keep only the last 500 in memory, and load older pages when the user scrolls to the top. Assign a cursorId to each page, and fetch the next batch before that cursor. Store paginated pages separately from the active window.
Should message reactions and edits trigger re-renders of the entire list?
No. Dispatch targeted actions like EDIT_MESSAGE with the message ID, and update only that message in the reducer. Let react-window's Row component do a shallow equality check and skip re-renders for unchanged messages.
How do I keep the scroll position when new messages arrive?
Use a ref to track whether the user is scrolled to the bottom. On new messages, only auto-scroll if they were at bottom. If they're reading history higher up, new messages don't disrupt their view.
Can I implement message search within a channel?
Yes. Add a search input above the message list that filters the messages array client-side (or makes an API call for older messages). Use a separate state for search results so the regular message list is unaffected.