Skip to main content

Push Notifications for React Chat

When a user receives a message while browsing another tab or app, they need to know immediately. Push notifications—whether as browser popups, in-app toasts, or sounds—are essential to keep your chat app top-of-mind. This guide covers implementing both browser Notifications API (which shows OS-level popups) and in-app notification UI, handling notification permissions, and avoiding notification spam.

The key challenge is respecting user preferences: don't notify if the user is already viewing the conversation, don't spam one notification per message, and always provide a way to mute notifications. We'll build a notification manager that intelligently decides when to notify based on visibility, focus, and the user's mute preferences.

Browser Notifications API Setup

Request permission from the user and display system-level notifications:

function useNotificationPermission() {
const [permission, setPermission] = useState(
Notification.permission || 'default'
);

const requestPermission = async () => {
if (!('Notification' in window)) {
console.warn('Browser does not support Notifications API');
setPermission('denied');
return;
}

if (Notification.permission === 'granted') {
setPermission('granted');
return;
}

if (Notification.permission !== 'denied') {
const result = await Notification.requestPermission();
setPermission(result);
}
};

return { permission, requestPermission };
}

Create a component to prompt for notification permissions:

function NotificationPermissionPrompt() {
const { permission, requestPermission } = useNotificationPermission();

if (permission === 'granted' || permission === 'denied') {
return null;
}

return (
<div
style={{
padding: '12px 16px',
backgroundColor: '#d1ecf1',
borderRadius: '4px',
marginBottom: '12px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div style={{ fontSize: '14px', color: '#0c5460' }}>
Enable notifications to stay updated on new messages
</div>
<button
onClick={requestPermission}
style={{
padding: '4px 12px',
fontSize: '12px',
backgroundColor: '#17a2b8',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Enable
</button>
</div>
);
}

Intelligent Notification Manager

Decide when to notify based on app visibility, focus, and mute settings:

class NotificationManager {
constructor() {
this.isPageVisible = !document.hidden;
this.isFocused = document.hasFocus();
this.mutedChannels = new Set(); // Channels the user muted
this.mutedUsers = new Set(); // DM users the user muted
this.lastNotificationTime = {}; // Debounce per channel/user

document.addEventListener('visibilitychange', () => {
this.isPageVisible = !document.hidden;
});

window.addEventListener('focus', () => {
this.isFocused = true;
});

window.addEventListener('blur', () => {
this.isFocused = false;
});
}

shouldNotify(messageSource, messageSourceId) {
// Don't notify if app is visible and focused
if (this.isPageVisible && this.isFocused) {
return false;
}

// Don't notify if muted
if (messageSource === 'channel' && this.mutedChannels.has(messageSourceId)) {
return false;
}
if (messageSource === 'dm' && this.mutedUsers.has(messageSourceId)) {
return false;
}

// Debounce: don't spam more than 1 notification per 3 seconds per source
const key = `${messageSource}:${messageSourceId}`;
const now = Date.now();
const lastTime = this.lastNotificationTime[key] || 0;

if (now - lastTime < 3000) {
return false;
}

this.lastNotificationTime[key] = now;
return true;
}

showNotification(title, options = {}) {
if (Notification.permission === 'granted') {
new Notification(title, {
icon: '/img/icon-192.png',
badge: '/img/badge-72.png',
...options,
});
}
}

toggleChannelMute(channelId) {
if (this.mutedChannels.has(channelId)) {
this.mutedChannels.delete(channelId);
} else {
this.mutedChannels.add(channelId);
}
}

toggleUserMute(userId) {
if (this.mutedUsers.has(userId)) {
this.mutedUsers.delete(userId);
} else {
this.mutedUsers.add(userId);
}
}
}

export const notificationManager = new NotificationManager();

Dispatching Notifications on New Messages

When a message arrives via WebSocket, decide whether to notify:

function ChatProvider({ children }) {
const [state, dispatch] = useReducer(chatReducer, initialState);
const wsClientRef = useRef(null);

useEffect(() => {
const wsClient = createChatWebSocketClient(wsUrl, {
onMessage: (payload) => {
const { channelId, userId, text } = payload;

// Add message to state
dispatch({ type: 'ADD_MESSAGE', payload });

// Decide if we should notify
if (notificationManager.shouldNotify('channel', channelId)) {
const sender = state.users[userId];
notificationManager.showNotification(
`${sender?.name || 'New message'} in #${channelId}`,
{
body: text.slice(0, 100),
tag: `channel:${channelId}`, // Replace older notifications
}
);

// Play sound
playNotificationSound();
}
},
// ... other handlers
});

wsClientRef.current = wsClient;
wsClient.connect();

return () => wsClient.close();
}, []);

return (
<ChatContext.Provider value={{ state, dispatch }}>
{children}
</ChatContext.Provider>
);
}

In-App Notification Toast

Show a toast notification in the app itself:

function NotificationToast({ message, onDismiss }) {
useEffect(() => {
const timer = setTimeout(onDismiss, 5000);
return () => clearTimeout(timer);
}, [onDismiss]);

return (
<div
style={{
padding: '12px 16px',
backgroundColor: '#007bff',
color: '#fff',
borderRadius: '4px',
marginBottom: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
maxWidth: '400px',
}}
>
<div>{message}</div>
<button
onClick={onDismiss}
style={{
background: 'none',
border: 'none',
color: '#fff',
cursor: 'pointer',
fontSize: '18px',
}}
>
×
</button>
</div>
);
}

function NotificationCenter() {
const [notifications, setNotifications] = useState([]);

const addNotification = (message) => {
const id = Math.random();
setNotifications((prev) => [...prev, { id, message }]);
return id;
};

const removeNotification = (id) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
};

return (
<div
style={{
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: 1000,
}}
>
{notifications.map((notif) => (
<NotificationToast
key={notif.id}
message={notif.message}
onDismiss={() => removeNotification(notif.id)}
/>
))}
</div>
);
}

Mute Controls in Channel/DM Headers

Let users toggle notifications for specific channels or DMs:

function ChannelNotificationSettings({ channelId }) {
const [isMuted, setIsMuted] = useState(
notificationManager.mutedChannels.has(channelId)
);

const handleToggle = () => {
notificationManager.toggleChannelMute(channelId);
setIsMuted(!isMuted);
};

return (
<button
onClick={handleToggle}
title={isMuted ? 'Unmute notifications' : 'Mute notifications'}
style={{
padding: '6px 12px',
fontSize: '12px',
backgroundColor: isMuted ? '#ffc107' : '#f0f0f0',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{isMuted ? '🔕 Muted' : '🔔 Notify'}
</button>
);
}

Notification Sound

Play a subtle sound when a message arrives:

function playNotificationSound() {
const audio = new Audio('/audio/notification.mp3');
audio.volume = 0.3;
audio.play().catch(() => {
// Browsers may block auto-play; silently fail
});
}

Badge Count for Unread Messages

Update the browser tab title or favicon to show unread count:

function useBadgeCount() {
const { state } = useContext(ChatContext);

useEffect(() => {
const totalUnread = Object.values(state.directMessages).reduce(
(sum, dm) => sum + dm.unreadCount,
0
);

// Update tab title
if (totalUnread > 0) {
document.title = `(${totalUnread}) Chat`;
} else {
document.title = 'Chat';
}

// Update favicon (if supported)
if (totalUnread > 0 && 'setAppBadge' in navigator) {
navigator.setAppBadge(totalUnread);
} else if ('clearAppBadge' in navigator) {
navigator.clearAppBadge();
}
}, [state.directMessages]);
}

Key Takeaways

  • Only notify when app is not focused: Check document.hasFocus() before showing notifications.
  • Debounce notifications: Don't show more than 1 per 3 seconds per channel to avoid spam.
  • Respect mute settings: Store muted channels/users and skip notifications for them.
  • Use tags to replace old notifications: Set tag: 'channel:general' so 3 messages don't show 3 separate popups.

Frequently Asked Questions

Can I send push notifications to the home screen even if the app is closed?

Yes, using Service Workers and Web Push API. However, that requires backend setup to send push messages from the server. This guide covers browser API (shown when the app is open or in a background tab). For true offline push, implement a Service Worker and subscribe to push notifications.

How do I handle notification clicks to jump to the message?

When creating a Notification, add a click handler that posts a message to the app or uses sessionStorage to signal which channel to open.

Should I notify on every message or batch them?

If the user is away and 10 messages arrive in quick succession, show 1 notification saying "10 new messages in #general" instead of 10 popups. Use the debounce strategy above.

How do I test notifications on localhost?

Browser Notifications API requires HTTPS or localhost. If testing remotely, use ngrok or deploy to a staging environment with an SSL cert.

Further Reading