Problem Statement
Build a small chat app where people in the same room see each other's messages instantly. There is no database. A Node server only relays messages between browsers that are connected at the same time.
Functional Requirements
- Join with a name: The user types a username and joins. Empty names are ignored.
- Rooms: Everyone starts in
general. The user can switch to any other room by name. Switching clears the message list. - Send and receive: A message goes to everyone in the current room, including the sender, and appears at once.
- Connection status: The header shows "Connected" or "Connecting…". Sending is disabled while offline.
- Leave: A Leave button logs out and closes the socket.
Technical Constraints
- React with TypeScript, Redux Toolkit for state, styled-components for styles.
- Socket.IO on both sides. The server keeps no history.
- The client reads the server URL from
VITE_SOCKET_URLand falls back tohttp://localhost:4000.
How the pieces talk
LogindispatchesloginSuccessafter a fake 500 ms delay. A real app would call an API here.AppwatchesisAuthenticated. When it turns true it opens the socket. Its cleanup closes the socket on logout or unmount.ChatRoomjoins a room as soon as the socket reportsconnect, and again after every reconnect.- The server sends each message back to the whole room with
io.to(room).emit, so the sender sees their own message the same way everyone else does.
Implementation Notes & Key Concepts
1. Keep the socket outside React
The socket lives in a module variable in socketService.ts, not in state. It is not something to render, and putting it in Redux would break serialisation. Components call plain functions (joinRoom, sendMessage). Incoming events become Redux actions, so components only read from the store.
socket.on("receive_message", (message: Message) => dispatch(addMessage(message)));initializeSocket returns early if a socket already exists. disconnectSocket removes the listeners and sets the variable back to null. Without that, React StrictMode (which runs effects twice in development) would leave two sockets that each dispatch every message.
2. One message type for both directions
Message is defined once in chatSlice.ts, and the service imports it. The timestamp is a number (Date.now()), so the client formats it with toLocaleTimeString and nothing has to parse strings.
3. Reducers that defend themselves
addMessage ignores a message for a room the user has left, and one whose id is already in the list. Both can happen around reconnects.
if (message.room !== state.currentRoom) return;
if (state.messages.some((existing) => existing.id === message.id)) return;4. The server checks what it relays
join_roomleaves every other room first, so a user is in one room at a time.send_messagechecks the shape and length of the message, and only relays it to a room the sender has joined. Without that check, any client could post into any room.- CORS allows only
CLIENT_ORIGIN(defaulthttp://localhost:5173).
5. Why port 4000 and not 6000
Chrome and Firefox refuse to connect to port 6000 (it is reserved for X11 and is on their blocked-ports list). The first version used 6000, so the client could never connect. The server now reads PORT and defaults to 4000.
Code Walkthrough
backend/server.js
- Express app, HTTP server and a Socket.IO server with CORS for the Vite dev origin.
isValidMessage(data): shape and length checks before a message is relayed.join_room,send_messageanddisconnecthandlers as described above.
frontend/src/services/socketService.ts
initializeSocket(dispatch): connects once and mapsconnect,disconnectandreceive_messageto actions.joinRoom,sendMessage,disconnectSocket: thin wrappers oversocket.emitandsocket.disconnect.
frontend/src/store/
authSlice.ts:loginStart,loginSuccess,loginFailure,logout.chatSlice.ts:messages,currentRoomandisConnected, with the guards inaddMessage.index.ts:configureStoreplus typeduseAppDispatchanduseAppSelectorhooks.
frontend/src/components/
Login.tsx: a controlled input. The user id comes fromcrypto.randomUUID(), so no extra package is needed.ChatRoom.tsx: header with room and status, a form to switch rooms, the message list (scrolls to the newest message), and the send form.
Running it
cd backend && npm install && npm run dev # http://localhost:4000
cd frontend && npm install && npm run dev # http://localhost:5173Open the client in two windows, join with different names, and send a message.
Interview follow-ups
- How would you keep message history? Store messages per room in a database such as Postgres or MongoDB, and send the last 50 when a user joins the room.
- How would you show that someone is typing? Emit a
typingevent with a short debounce, and clear the indicator after two seconds without one. - How do you keep messages in order and confirm delivery? Stamp each message on the server and sort by that time. Use Socket.IO acks (
socket.emit(event, data, callback)) to mark a message as sent. - What changes when you run several Node servers? Each server only knows its own sockets, so add the Socket.IO Redis adapter to pass room messages between servers.