So, you’re prepping for a React Senior Engineer interview, huh? The surprising truth is, most senior interviews aren’t about memorizing obscure hooks or the latest syntax. They’re about how you’d architect a robust, scalable, and maintainable React application under pressure, and how you’d debug it when it inevitably breaks.

Let’s dive into how a senior engineer might approach building a real-time notification system. Imagine we need to display unread message counts next to user avatars in a chat application.

First, the frontend needs to subscribe to a WebSocket endpoint.

// src/services/notificationService.js
import io from 'socket.io-client';

const socket = io('http://localhost:3001'); // Connect to the backend

export const subscribeToNotifications = (userId, callback) => {
  socket.emit('joinNotifications', { userId });

  socket.on('newNotification', (data) => {
    if (data.recipientId === userId) {
      callback(data.messageCount); // Update UI with new count
    }
  });

  // Cleanup on component unmount
  return () => {
    socket.off('newNotification');
    socket.emit('leaveNotifications', { userId });
  };
};

On the backend, this would be handled by a Socket.IO server:

// server/server.js (simplified)
const io = require('socket.io')(3001);

io.on('connection', (socket) => {
  socket.on('joinNotifications', ({ userId }) => {
    socket.join(userId); // Join a room specific to the user
    console.log(`User ${userId} joined notification room.`);
  });

  socket.on('leaveNotifications', ({ userId }) => {
    socket.leave(userId);
    console.log(`User ${userId} left notification room.`);
  });

  // Function to send updates (e.g., after a new message)
  const sendNotificationUpdate = (userId, messageCount) => {
    io.to(userId).emit('newNotification', { recipientId: userId, messageCount });
    console.log(`Sent notification update to ${userId}: ${messageCount}`);
  };

  // Example: Simulate a new message arriving and updating count
  setTimeout(() => {
    sendNotificationUpdate('user123', 5);
  }, 5000);
});

Now, in a React component, we’d use a custom hook to manage this subscription and state.

// src/hooks/useNotifications.js
import { useState, useEffect } from 'react';
import { subscribeToNotifications } from '../services/notificationService';

export const useNotifications = (userId) => {
  const [unreadCount, setUnreadCount] = useState(0);

  useEffect(() => {
    if (!userId) return;

    const unsubscribe = subscribeToNotifications(userId, (newCount) => {
      setUnreadCount(newCount);
    });

    // Cleanup function
    return () => {
      unsubscribe();
    };
  }, [userId]); // Re-subscribe if userId changes

  return unreadCount;
};

And here’s how a component would consume it:

// src/components/NotificationBadge.js
import React from 'react';
import { useNotifications } from '../hooks/useNotifications';

const NotificationBadge = ({ userId }) => {
  const unreadCount = useNotifications(userId);

  if (unreadCount === 0) {
    return null; // Don't render anything if no unread messages
  }

  return (
    <span className="notification-badge">
      {unreadCount}
    </span>
  );
};

export default NotificationBadge;

The problem this solves is providing a seamless, real-time experience for users to see their unread message counts without constant polling, which is inefficient. Internally, it leverages WebSockets for persistent, bi-directional communication. The socket.io-client establishes a connection, and socket.io on the server manages connections and broadcasts messages to specific rooms (socket.join(userId)). The React useEffect hook ensures the subscription is set up when the component mounts and cleaned up when it unmounts, preventing memory leaks and redundant connections. The custom hook useNotifications abstracts the WebSocket logic, making the NotificationBadge component purely declarative.

The exact levers you control here are the connection URL (http://localhost:3001), the event names (joinNotifications, newNotification, leaveNotifications), and how you process the incoming data (callback(data.messageCount)). You also manage the lifecycle via useEffect’s cleanup function.

A common pitfall is forgetting to handle the userId being null or undefined initially, which can lead to the socket.emit('joinNotifications', { userId }) call failing or sending an invalid payload. The if (!userId) return; check in the useEffect hook prevents this. Another is not cleaning up the socket listeners, which can cause memory leaks and unexpected behavior if the component re-renders or unmounts and re-mounts. The return function from useEffect is crucial for this cleanup.

The next concept you’ll grapple with is managing state updates across multiple components efficiently, perhaps using a global state management library or React Context to share the unreadCount if it needs to be displayed in many places.

Want structured learning?

Take the full React course →