Skip to main content

Load More Messages

Loading all messages at once isn't feasible for active chat rooms - it would be slow and use a ton of bandwidth. That's why I implemented the "load more messages" feature to fetch chat history in chunks as the user scrolls up.

How Message Loading Works

Instead of loading the entire chat history when a user enters a chat, I only load the most recent messages and then fetch older messages as needed:

// In ChatContext.jsx
const [messages, setMessages] = useState([]);
const [hasMoreMessages, setHasMoreMessages] = useState(true);
const [loadingMoreMessages, setLoadingMoreMessages] = useState(false);
const [lastLoadedMessageId, setLastLoadedMessageId] = useState(null);
const MESSAGES_PER_PAGE = 50;

// Initial message loading when entering a chat
useEffect(() => {
if (!currentChat?.id) return;

const loadInitialMessages = async () => {
setMessages([]);
setLoading(true);

try {
// Query most recent messages, limited to a reasonable number
const messagesRef = ref(db, `messages/${currentChat.id}`);
const messagesQuery = query(
messagesRef,
orderByChild('timestamp'),
limitToLast(MESSAGES_PER_PAGE)
);

const snapshot = await get(messagesQuery);

if (snapshot.exists()) {
const messagesData = snapshot.val();
const messagesList = Object.keys(messagesData).map(key => ({
id: key,
...messagesData[key]
}));

// Sort messages by timestamp (oldest first)
messagesList.sort((a, b) => a.timestamp - b.timestamp);

setMessages(messagesList);

// If we have a full page, assume there might be more
setHasMoreMessages(messagesList.length >= MESSAGES_PER_PAGE);

// Store the ID of the oldest message for pagination
if (messagesList.length > 0) {
setLastLoadedMessageId(messagesList[0].id);
}
} else {
setMessages([]);
setHasMoreMessages(false);
}
} catch (error) {
console.error('Error loading messages:', error);
} finally {
setLoading(false);
}
};

loadInitialMessages();

// Also set up real-time listener for new messages
// ...
}, [currentChat?.id]);

// Function to load older messages
const loadMoreMessages = async () => {
if (!currentChat?.id || !hasMoreMessages || loadingMoreMessages) return;

setLoadingMoreMessages(true);

try {
// Get the oldest message we currently have
const oldestMessageSnapshot = await get(
ref(db, `messages/${currentChat.id}/${lastLoadedMessageId}`)
);

if (!oldestMessageSnapshot.exists()) {
setHasMoreMessages(false);
return;
}

const oldestMessage = oldestMessageSnapshot.val();

// Query messages before our oldest message
const messagesRef = ref(db, `messages/${currentChat.id}`);
const messagesQuery = query(
messagesRef,
orderByChild('timestamp'),
endBefore(oldestMessage.timestamp),
limitToLast(MESSAGES_PER_PAGE)
);

const snapshot = await get(messagesQuery);

if (snapshot.exists()) {
const messagesData = snapshot.val();
const olderMessagesList = Object.keys(messagesData).map(key => ({
id: key,
...messagesData[key]
}));

// Sort by timestamp (oldest first)
olderMessagesList.sort((a, b) => a.timestamp - b.timestamp);

// Update state with the older messages prepended
setMessages(prev => [...olderMessagesList, ...prev]);

// If we got fewer messages than the page size, we've reached the beginning
setHasMoreMessages(olderMessagesList.length >= MESSAGES_PER_PAGE);

// Update the oldest message ID for next pagination
if (olderMessagesList.length > 0) {
setLastLoadedMessageId(olderMessagesList[0].id);
}
} else {
// No more messages found
setHasMoreMessages(false);
}
} catch (error) {
console.error('Error loading more messages:', error);
} finally {
setLoadingMoreMessages(false);
}
};

ChatArea UI Integration

In the ChatArea component, I added a scroll handler to detect when the user scrolls to the top and load more messages:

// In ChatArea.jsx
const messagesContainerRef = useRef(null);
const oldScrollHeightRef = useRef(0);

const handleScroll = useCallback(() => {
const container = messagesContainerRef.current;
if (!container) return;

// If scrolled near the top, load more messages
if (container.scrollTop === 0 && hasMoreMessages && !loadingMoreMessages) {
// Store current scroll height to maintain position after loading
oldScrollHeightRef.current = container.scrollHeight;

// Load more messages
loadMoreMessages();
}
}, [hasMoreMessages, loadingMoreMessages, loadMoreMessages]);

// After loading more messages, maintain scroll position
useEffect(() => {
// Only run this effect when messages array length changes
if (messages.length > 0 && !loading) {
const container = messagesContainerRef.current;
if (!container) return;

// If we have a stored scroll height, adjust scroll position
if (oldScrollHeightRef.current > 0) {
const newScrollTop = container.scrollHeight - oldScrollHeightRef.current;
if (newScrollTop > 0) {
container.scrollTop = newScrollTop;
}
oldScrollHeightRef.current = 0;
}
}
}, [messages.length, loading]);

// In the JSX
return (
<div
className={styles.messagesContainer}
ref={messagesContainerRef}
onScroll={handleScroll}
>
{/* "Load More" button (alternative UI) */}
{hasMoreMessages && (
<div className={styles.loadMoreContainer}>
<button
className={styles.loadMoreButton}
onClick={() => {
oldScrollHeightRef.current = messagesContainerRef.current?.scrollHeight || 0;
loadMoreMessages();
}}
disabled={loadingMoreMessages}
>
{loadingMoreMessages ? 'Loading...' : 'Load More Messages'}
</button>
</div>
)}

{/* Loading indicator */}
{loadingMoreMessages && (
<div className={styles.loadingIndicator}>
<div className={styles.spinner}></div>
</div>
)}

{/* Messages */}
{renderMessages()}
</div>
);

User Experience Considerations

I had to be careful with the message loading experience:

  1. Scroll Position: When loading older messages, I needed to maintain the user's current view position rather than jumping to the top or bottom.

  2. Loading Indicators: I added visual feedback to show when messages are loading:

// Loading indicator component
function LoadingIndicator() {
return (
<div className={styles.loadingContainer}>
<div className={styles.loadingDots}>
<div className={styles.dot}></div>
<div className={styles.dot}></div>
<div className={styles.dot}></div>
</div>
</div>
);
}
  1. Smooth Transitions: I made sure new messages blend in seamlessly:
/* CSS for smooth message loading */
.messagesContainer {
scroll-behavior: smooth; /* For smooth scrolling except when loading old messages */
}

.messageItem {
animation: fadeIn 0.2s ease-in-out;
}

@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}

/* When loading, temporarily disable smooth scrolling */
.loading {
scroll-behavior: auto;
}

Mobile Optimization

On mobile devices, the scrolling behavior needed some tweaking:

// Mobile-specific scroll behavior
useEffect(() => {
const isMobile = window.innerWidth < 768;

if (isMobile) {
// On mobile, we throttle the scroll event to improve performance
const handleThrottledScroll = throttle(handleScroll, 200);

const container = messagesContainerRef.current;
if (container) {
container.addEventListener('scroll', handleThrottledScroll);
return () => container.removeEventListener('scroll', handleThrottledScroll);
}
} else {
// On desktop, use the normal scroll handler
return () => {};
}
}, [handleScroll]);

Performance Optimizations

To keep the app snappy even with hundreds or thousands of messages:

Message Virtualization

For very active chats, I implemented virtualization:

// In ChatArea.jsx - Using react-window for virtualized rendering
import { FixedSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';

// Virtualized message list for large conversations
const renderVirtualizedMessages = () => {
// Only use virtualization if we have lots of messages
if (messages.length < 100) {
return renderMessages(); // Use normal rendering for small lists
}

return (
<AutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
itemCount={messages.length}
itemSize={80} // Approximate height per message
overscanCount={5} // Number of items to render outside visible area
initialScrollOffset={height * 10} // Start scrolled down
>
{({ index, style }) => {
const message = messages[index];
return (
<div style={style}>
<MessageItem
message={message}
isOwnMessage={message.sender === user?.uid}
/>
</div>
);
}}
</List>
)}
</AutoSizer>
);
};

Message Batching

When loading more messages, I made sure to batch updates to avoid performance issues:

// In ChatContext.jsx - Batch update messages
const loadMoreMessages = async () => {
// ... existing code

try {
// ... fetch messages

// Use a functional update to batch message state changes
setMessages(prev => {
const uniqueMessages = new Map();

// Add existing messages to map (to avoid duplicates)
prev.forEach(msg => uniqueMessages.set(msg.id, msg));

// Add new messages to map
olderMessagesList.forEach(msg => uniqueMessages.set(msg.id, msg));

// Convert back to array and sort
const combined = Array.from(uniqueMessages.values());
return combined.sort((a, b) => a.timestamp - b.timestamp);
});

} catch (error) {
console.error('Error loading more messages:', error);
}
};

Technical Challenges

Maintaining Scroll Position

One of the trickiest challenges was preserving the user's scroll position when loading older messages:

useEffect(() => {
if (messages.length > 0 && !loadingMoreMessages) {
// Get container and calculate heights
const container = messagesContainerRef.current;
if (!container) return;

if (previousMessagesLength.current < messages.length) {
// New messages added at the bottom (someone sent a message)
if (isAtBottom.current) {
scrollToBottom();
}
} else if (previousMessagesLength.current && messages.length > previousMessagesLength.current) {
// Messages were added at the top (loaded older messages)
if (oldScrollHeightRef.current > 0) {
const newScrollPosition = container.scrollHeight - oldScrollHeightRef.current;

// Use requestAnimationFrame for more reliable scroll position updating
requestAnimationFrame(() => {
container.scrollTop = newScrollPosition > 0 ? newScrollPosition : 0;
});
}
}

previousMessagesLength.current = messages.length;
}
}, [messages.length, loadingMoreMessages, scrollToBottom]);

Firebase Query Limits

Firebase has some limitations on complex queries, which required some workarounds:

// Handle Firebase query limitations with composite keys
const loadMoreMessages = async () => {
// ... existing code

try {
let olderMessagesQuery;

// Firebase doesn't support multiple range queries, so we have to query by timestamp only
if (oldestTimestamp) {
olderMessagesQuery = query(
messagesRef,
orderByChild('timestamp'),
endBefore(oldestTimestamp),
limitToLast(MESSAGES_PER_PAGE)
);
} else {
// Fallback approach if we somehow don't have a timestamp
olderMessagesQuery = query(
messagesRef,
orderByKey(),
endBefore(lastLoadedMessageId),
limitToLast(MESSAGES_PER_PAGE)
);
}

// ... rest of the function
} catch (error) {
console.error('Error loading more messages:', error);
}
};

Handling Real-time Updates During Pagination

Ensuring new messages don't interfere with pagination required careful handling:

// In ChatContext.jsx
// Set up real-time listener for new messages
useEffect(() => {
if (!currentChat?.id) return;

// Get reference to most recent messages
const recentMessagesRef = query(
ref(db, `messages/${currentChat.id}`),
orderByChild('timestamp'),
limitToLast(1) // Just listen for the newest message
);

const unsubscribe = onChildAdded(recentMessagesRef, (snapshot) => {
const newMessage = {
id: snapshot.key,
...snapshot.val()
};

// Avoid duplicates by checking if the message already exists in our state
setMessages(prev => {
// Don't add if we already have this message
if (prev.some(msg => msg.id === newMessage.id)) {
return prev;
}

// Add the new message and resort
const updated = [...prev, newMessage];
return updated.sort((a, b) => a.timestamp - b.timestamp);
});
});

return () => unsubscribe();
}, [currentChat?.id]);

Future Improvements

If I had more time to work on this feature, I'd add:

  1. Message Search: Allow searching within historical messages
  2. Jump to Date: Let users jump to a specific date in the conversation
  3. Smarter Preloading: Predictively load messages based on scroll velocity
  4. Background Loading: Fetch older messages in the background during idle time
  5. Message Group Collapsing: Collapse very old message groups to save space
  6. Cached Message History: Store previously loaded messages in local storage

The load more messages feature has made the chat app much more scalable - it can now handle years of chat history without performance issues.