Skip to main content

Typing Indicators & Presence in React

Typing indicators and presence badges create the illusion that a chat is alive: you see "Alice is typing..." and a green dot next to Bob's name, giving you immediate feedback that others are engaged. Building these features requires broadcasting frequent, ephemeral state (typing, away, idle) without creating network overhead. This guide shows how to debounce broadcasts, manage presence efficiently, and keep typing indicators responsive.

The tricky part is that typing and presence updates happen constantly—potentially hundreds of times per second as users move between windows or resume typing. Broadcasting every state change would overwhelm the server and create lag. Instead, we'll use debouncing to batch updates, send them less frequently, and discard old state after a timeout to prevent stale "Alice is typing..." after she closed the browser.

Typing Indicator Pattern

A user starts typing, you broadcast to their current channel with a delay to avoid noise:

function useTypingIndicator(channelId) {
const { state, dispatch } = useContext(ChatContext);
const wsClientRef = useRef(null); // Get from context if available
const typingTimeoutRef = useRef(null);
const lastBroadcastRef = useRef(0);

const broadcastTyping = useCallback((isTyping) => {
const now = Date.now();
const timeSinceLastBroadcast = now - lastBroadcastRef.current;

// Debounce: only broadcast if at least 300ms has passed
if (timeSinceLastBroadcast < 300) {
return;
}

lastBroadcastRef.current = now;

wsClientRef.current?.send({
type: 'typing',
payload: {
channelId,
userId: state.currentUser.id,
isTyping,
},
});
}, [channelId, state.currentUser.id]);

const handleInputChange = useCallback((text) => {
// Clear the previous timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}

// Broadcast that user is typing
broadcastTyping(true);

// After 2 seconds of inactivity, broadcast that user is NOT typing
typingTimeoutRef.current = setTimeout(() => {
broadcastTyping(false);
}, 2000);
}, [broadcastTyping]);

// Cleanup on unmount
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);

return { handleInputChange, broadcastTyping };
}

Displaying Typing Indicators

Show a list of users currently typing in the active channel:

function TypingIndicator({ channelId }) {
const { state } = useContext(ChatContext);
const channel = state.channels[channelId];
const typingUserIds = channel?.typingUsers || [];

if (typingUserIds.length === 0) {
return null;
}

const typingUsers = typingUserIds.map(uid => state.users[uid]?.name || uid);
const text =
typingUsers.length === 1
? `${typingUsers[0]} is typing...`
: `${typingUsers.join(', ')} are typing...`;

return (
<div
style={{
padding: '8px 12px',
fontSize: '13px',
color: '#888',
fontStyle: 'italic',
}}
>
{text}
</div>
);
}

Presence State and Detection

Track who is online and where:

// Global state shape
const presence = {
'user_1': {
status: 'online', // 'online', 'away', 'idle', 'offline'
currentChannel: 'general', // or 'dm:user_2'
lastSeen: 1717344020000,
lastActivity: 1717344020000,
},
'user_2': {
status: 'away',
currentChannel: null,
lastSeen: 1717344000000,
lastActivity: 1717343900000,
},
};

Create a hook to track the current user's presence:

function usePresenceTracker(currentChannelId) {
const { state, dispatch } = useContext(ChatContext);
const wsClientRef = useRef(null);
const activityTimeoutRef = useRef(null);
const presenceIntervalRef = useRef(null);

// Broadcast presence periodically
useEffect(() => {
const broadcastPresence = () => {
const status = (() => {
const now = Date.now();
const timeSinceActivity = now - state.currentUser.lastActivity;

if (timeSinceActivity > 5 * 60 * 1000) return 'idle'; // 5 min
if (timeSinceActivity > 2 * 60 * 1000) return 'away'; // 2 min
return 'online';
})();

wsClientRef.current?.send({
type: 'presence',
payload: {
userId: state.currentUser.id,
status,
currentChannel: currentChannelId,
lastActivity: state.currentUser.lastActivity,
},
});
};

// Send presence every 30 seconds
presenceIntervalRef.current = setInterval(broadcastPresence, 30000);

// Initial broadcast
broadcastPresence();

return () => clearInterval(presenceIntervalRef.current);
}, [currentChannelId, state.currentUser]);

// Track activity (mouse, keyboard)
useEffect(() => {
const handleActivity = () => {
const now = Date.now();

// Debounce activity updates
if (now - state.currentUser.lastActivity < 1000) {
return;
}

dispatch({
type: 'UPDATE_ACTIVITY',
payload: { userId: state.currentUser.id, timestamp: now },
});

// Clear idle timeout
if (activityTimeoutRef.current) {
clearTimeout(activityTimeoutRef.current);
}

// Set idle timeout: 5 minutes of inactivity
activityTimeoutRef.current = setTimeout(() => {
dispatch({
type: 'SET_PRESENCE_STATUS',
payload: { userId: state.currentUser.id, status: 'idle' },
});
}, 5 * 60 * 1000);
};

// Listen for user activity
const events = ['mousedown', 'keydown', 'touchstart'];
events.forEach(event => document.addEventListener(event, handleActivity));

return () => {
events.forEach(event =>
document.removeEventListener(event, handleActivity)
);
if (activityTimeoutRef.current) {
clearTimeout(activityTimeoutRef.current);
}
};
}, [state.currentUser, dispatch]);
}

Presence Badge Component

Show online status next to user names and avatars:

function PresenceBadge({ userId }) {
const { state } = useContext(ChatContext);
const presence = state.presence[userId];

const statusColor = {
online: '#28a745',
away: '#ffc107',
idle: '#6c757d',
offline: '#999',
};

const statusLabel = {
online: 'Online',
away: 'Away',
idle: 'Idle',
offline: 'Offline',
};

const status = presence?.status || 'offline';

return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<img
src={state.users[userId]?.avatar}
alt={state.users[userId]?.name}
style={{ width: '32px', height: '32px', borderRadius: '50%' }}
/>
<div
title={statusLabel[status]}
style={{
position: 'absolute',
bottom: 0,
right: 0,
width: '10px',
height: '10px',
backgroundColor: statusColor[status],
borderRadius: '50%',
border: '2px solid white',
}}
/>
</div>
);
}

Server-Side Presence Timeout

The server should clear stale typing and presence after a timeout:

// Example server logic (Node.js / Express-ws)
const TYPING_TIMEOUT = 3000; // Clear typing after 3 sec
const PRESENCE_TIMEOUT = 60000; // Clear presence after 60 sec

ws.on('message', (msg) => {
const data = JSON.parse(msg);

if (data.type === 'typing') {
const { channelId, userId, isTyping } = data.payload;

if (isTyping) {
// Add to typingUsers set
typingUsers.add(`${channelId}:${userId}`);

// Auto-clear after timeout
setTimeout(() => {
typingUsers.delete(`${channelId}:${userId}`);
broadcast({
type: 'typing',
payload: { channelId, userId, isTyping: false },
});
}, TYPING_TIMEOUT);
} else {
typingUsers.delete(`${channelId}:${userId}`);
}

// Broadcast to all connected clients in this channel
broadcast({
type: 'typing',
payload: data.payload,
});
}
});

Key Takeaways

  • Debounce typing broadcasts: Send updates every 300–500ms, not on every keystroke.
  • Auto-clear typing after timeout: If no new keystroke arrives within 2 seconds, assume the user stopped typing.
  • Presence is derived from activity: Calculate status (online/away/idle) from the lastActivity timestamp, not a stored value.
  • Batch presence updates: Send presence every 30 seconds, not on every activity.

Frequently Asked Questions

How do I prevent "is typing..." from lingering after a user closes their browser?

The server should have a timeout for typing (3 seconds) and presence (60 seconds). If the client doesn't send a heartbeat or explicit "no longer typing" message, the server clears it. This is why debounced broadcasting + server-side timeouts are essential.

Should I show typing indicators in DMs as well as channels?

Yes, use the same pattern. When typing a DM, broadcast { type: 'typing', channelId: dmKey, userId: currentUser.id, isTyping: true }.

Can I reduce the battery drain from constant activity polling?

Instead of polling every 1 second, listen to real events (mousedown, keydown) and update activity only when they fire. Use a 1-second debounce to avoid network spam.

How do I handle presence for users on multiple browser tabs?

Track presence per client session ID (a UUID assigned on page load), not per user. If one tab is idle but another is active, the user's presence is "online". Sync across tabs via SharedWorker or localStorage events.

Further Reading