Build a React Real-Time Chat Client
Build a tested React chat client with user search and live Socket.IO updates.
Introduction
30 Second Summary
Messaging feels effortless when replies appear the moment someone sends them. A stale screen quickly leaves people unsure whether a conversation is still active.
In this project, you will build a React chat client for private one-to-one conversations. It uses session restoration for cookie-backed authentication while Socket.IO keeps each conversation current without a page refresh.
What You'll Build
Picture two signed-in browser windows where a newly started conversation appears instantly with each message landing exactly once on both screens.
By the end of this project, you'll have:
- A cookie-backed sign-in flow that survives a reload. Logging out returns you to the login form.
- A two-column chat experience for opening history plus sending messages. The New Chat flow finds users by username without creating duplicate rooms.
- A tested real-time update loop that shows each incoming conversation or message exactly once. Backend contract tests plus React interaction tests verify the behavior.
- Secret Mission: Prove reconnection resilience by interrupting the Express process. After recovery, each message must still appear exactly once without duplicate listeners.
Are there any prerequisites?
This project starts from the chat-api repository on main at commit c2452268865a410258187659c6a4a62081745fd9.
You also need a working .env file plus at least two registered test users.
Before We Start
Before the hands-on work begins, lock in the focused chat client you are building and why it matters. Your goal is a one-to-one browser experience that reuses the existing backend for secure sessions and live messaging.
Set Up the React Client
Your existing Express API already stores users plus messages. The missing piece is a browser application that turns those contracts into a visible chat experience.
A Vite powered React client provides that interface. Vitest provides the browser-like test foundation for the interactions you add later.
Why Vite for This Client?
Vite keeps this project focused on a single-page client. The existing Express API remains responsible for the server.
Its development proxy sends browser requests for the API plus Socket.IO through the client origin. This keeps local cookie-based requests straightforward.
In this step, get ready to:
- Verify the local JavaScript toolchain.
- Start the existing database plus API.
- Create the React client plus its test environment.
Verify the toolchain and start the services
Vite 8.2.2 requires Node.js 20.19.0 or newer. Docker Desktop runs the existing PostgreSQL database without changing the root project.
- Press the Windows key to open Windows Search.
- Enter Visual Studio Code in the search field.
- Select Visual Studio Code from the results.
- Select File from the top menu.
- Select Open Folder.
- Choose the existing chat-api repository folder.
- Select Terminal from the top menu.
- Select New Terminal.
The terminal starts inside the existing chat-api folder. This keeps every command scoped to the repository you already prepared.
- Check the installed Node.js version by running this command:
node --version
✔️ I see version 20.19.0 or higher
Your Node.js version meets the client requirement. You can continue with the installed runtime.
ⓧ I see an older version
Your installed runtime is below the minimum used by this client. Upgrade it before installing the frontend packages.
- Visit the official Node.js download page.
- Install Node.js 20.19.0 or newer for Windows.
- Restart Visual Studio Code after the installer finishes.
- Return to the chat-api terminal.
- Check the upgraded version by running this command:
node --version
You should now see version 20.19.0 or higher.
ⓧ Command not found
Windows cannot find the Node.js executable. Install Node.js before continuing.
- Visit the official Node.js download page.
- Install Node.js 20.19.0 or newer for Windows.
- Restart Visual Studio Code after the installer finishes.
- Return to the chat-api terminal.
- Check the installation by running this command:
node --version
You should now see a Node.js version of 20.19.0 or higher.
- Verify that npm is available by running this command:
npm --version
The terminal prints the installed npm version. That result confirms the package manager is ready.
Still Missing Node.js or npm?
Restart Visual Studio Code so its terminal receives the updated Windows path. Run both version commands again in a fresh terminal.
If either command still fails, confirm that the Node.js installer completed before reopening VS Code.
Help me check my Node.js setup.
The database must be available before Express can serve authenticated requests. The Compose command starts PostgreSQL in the background so the terminal becomes available again.
- Start the existing PostgreSQL service by running this command:
docker compose up --detach
The first startup can take a few minutes while Docker prepares PostgreSQL. The command returns control to the terminal when the service has started.
Does Docker Fail to Start?
Confirm Docker Desktop is running before repeating the Compose command. Also confirm the terminal is inside the existing chat-api folder.
Help me start the PostgreSQL container.
The Express development process stays active while it serves requests on port 5000. Keep this terminal running after the server starts.
- Start the existing API from the repository root by running this command:
npm run dev
The terminal reports that Express is running on port 5000. It also confirms the PostgreSQL connection.
- Enter http://localhost:5000/api/health in your browser address bar.
- Load the health endpoint.
You should see a successful JSON response stating that the server is running. This proves the API plus database are ready for the client.
Is the Health Check Unavailable?
Return to the API terminal and look for a database connection problem. Confirm the Docker Compose command completed before restarting the API.
Check that no other process is already using port 5000.
Help me restore the API health check.
Build the client scaffold
The client needs an HTML entry point plus a React entry point. Its Vite configuration also routes /api requests plus /socket.io connections to the existing backend.
- Select the chat-api folder in the VS Code Explorer sidebar.
- Click New Folder in the Explorer toolbar.
- Enter client as the folder name.
- Select the new client folder.
- Click New Folder in the Explorer toolbar.
- Enter src as the nested folder name.
The Explorer now shows client/src inside the existing repository. The root backend files remain in place.
- Select the client folder in the Explorer sidebar.
- Click New File in the Explorer toolbar.
- Enter package.json as the file name.
- Paste this package manifest into client/package.json:
{
"name": "chat-client",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20.19.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8",
"socket.io-client": "4.8.3"
},
"devDependencies": {
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.6",
"@vitejs/plugin-react": "6.1.1",
"jsdom": "30.0.1",
"vite": "8.2.2",
"vitest": "4.1.11"
}
}
What Does This Manifest Configure?
- The engines field records the minimum Node.js version required by the client.
- The dependencies section pins React plus the Socket.IO browser client.
- The devDependencies section pins Vite plus the browser interaction testing tools.
- The scripts section provides consistent commands for development plus testing.
- Save client/package.json.
- Confirm package.json appears directly inside client in the Explorer sidebar.
Does the Manifest Show an Error?
Check the commas plus double quotes in client/package.json. JSON rejects missing commas plus trailing commas.
Help me check my package manifest.
- Select the client folder in the Explorer sidebar.
- Click New File in the Explorer toolbar.
- Enter vite.config.js as the file name.
- Paste this configuration into client/vite.config.js:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
strictPort: true,
proxy: {
"/api": "http://localhost:5000",
"/socket.io": {
target: "ws://localhost:5000",
ws: true,
},
},
},
test: {
environment: "jsdom",
setupFiles: "./src/setupTests.js",
},
});
What Does This Configuration Do?
- The React plugin lets Vite process JSX files.
- The development server uses port 3000.
- The proxy forwards API requests to Express on port 5000.
- The test section gives future component tests a jsdom browser environment.
- Save client/vite.config.js.
- Confirm vite.config.js appears directly inside client.
Does Vite Reject the Configuration?
Confirm the file is named vite.config.js. Check that both proxy entries remain inside the proxy object.
Help me check my Vite configuration.
- Select the client folder in the Explorer sidebar.
- Click New File in the Explorer toolbar.
- Enter index.html as the file name.
- Paste this entry document into client/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chat Client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
How Does the Page Start?
The browser loads /src/main.jsx as a module. React uses the root element as its rendering destination.
- Save client/index.html.
- Confirm index.html appears directly inside client.
- Select the src folder inside client.
- Click New File in the Explorer toolbar.
- Enter main.jsx as the file name.
- Paste this React entry point into client/src/main.jsx:
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
createRoot(document.getElementById("root")).render(<App />);
What Does the React Entry Point Do?
The entry point imports the application plus its stylesheet. createRoot() mounts the application inside the HTML element named root.
- Save client/src/main.jsx.
- Confirm main.jsx appears inside client/src.
- Select the src folder inside client.
- Click New File in the Explorer toolbar.
- Enter App.jsx as the file name.
- Paste this starter component into client/src/App.jsx:
export default function App() {
return <main className="loading-screen">Chat Client</main>;
}
What Does the Starter Component Show?
The component returns one visible interface with the project name. This gives you an immediate browser check before the authentication flow is added.
- Save client/src/App.jsx.
- Confirm App.jsx appears inside client/src.
- Select the src folder inside client.
- Click New File in the Explorer toolbar.
- Enter styles.css as the file name.
- Paste this starter stylesheet into client/src/styles.css:
:root {
font-family: Inter, system-ui, sans-serif;
color: #182033;
background: #eef2f8;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
button { cursor: pointer; }
.loading-screen,
.auth-shell {
min-height: 100vh;
display: grid;
place-items: center;
}
What Does the Starter Style Add?
The global rules establish the client typography plus background. The .loading-screen selector centers the starter interface across the viewport.
- Save client/src/styles.css.
- Confirm styles.css appears inside client/src.
- Select the src folder inside client.
- Click New File in the Explorer toolbar.
- Enter setupTests.js as the file name.
- Paste this test setup import into client/src/setupTests.js:
import "@testing-library/jest-dom/vitest";
What Does the Test Setup Provide?
The import adds DOM-focused assertions to Vitest. Future interaction tests can make readable checks against rendered elements.
- Save client/src/setupTests.js.
- Confirm setupTests.js appears inside client/src.
Is a Client File Missing?
Expand client plus client/src in the Explorer sidebar. Confirm each file uses the exact name shown in the instructions.
Help me check my client scaffold.
✔️ Awesome, I've got everything!
Great. Your client scaffold now contains the package manifest plus the browser and test entry points.
ⓧ I'd like to double check the full code
Compare each client file with the complete starter versions below.
{
"name": "chat-client",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20.19.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8",
"socket.io-client": "4.8.3"
},
"devDependencies": {
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.6",
"@vitejs/plugin-react": "6.1.1",
"jsdom": "30.0.1",
"vite": "8.2.2",
"vitest": "4.1.11"
}
}
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
strictPort: true,
proxy: {
"/api": "http://localhost:5000",
"/socket.io": {
target: "ws://localhost:5000",
ws: true,
},
},
},
test: {
environment: "jsdom",
setupFiles: "./src/setupTests.js",
},
});
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chat Client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
createRoot(document.getElementById("root")).render(<App />);
export default function App() {
return <main className="loading-screen">Chat Client</main>;
}
import "@testing-library/jest-dom/vitest";
:root {
font-family: Inter, system-ui, sans-serif;
color: #182033;
background: #eef2f8;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
button { cursor: pointer; }
.loading-screen,
.auth-shell {
min-height: 100vh;
display: grid;
place-items: center;
}
Install and verify the client
The package manifest pins every dependency used by the finished client. Installing from inside client keeps those packages separate from the existing backend dependencies.
- Right-click the client folder in the Explorer sidebar.
- Select Open in Integrated Terminal.
- Install the pinned client dependencies by running this command:
npm install
The installation can take a few minutes while npm downloads the packages. The terminal returns to the prompt when the client dependencies are ready.
- Confirm node_modules plus the generated lockfile appear inside client.
Does the Installation Fail?
Confirm the terminal path ends with client. Check that package.json contains valid JSON before trying again.
If npm reports an unsupported engine, repeat the Node.js version check and upgrade to 20.19.0 or newer.
Help me diagnose the client installation.
The Vite process occupies this terminal while it serves the client. Keep it running so browser changes remain available at port 3000.
Before you start the client, what project title do you expect the browser to show?
- Start the client development server by running this command:
npm run dev
The terminal shows that the development server is available on port 3000.
- Enter http://localhost:3000 in your browser address bar.
- Load the client page.
You should see Chat Client centered on the page. That is your first visible React interface connected to the Vite development server.
Is the Client Page Missing?
Confirm the client terminal is still running. Check that the browser uses port 3000.
If Vite reports an import problem, compare main.jsx with the full-code tab and confirm the referenced files exist inside client/src.
Help me restore the starter page.
The starter project does not contain interaction tests yet. A no-tests flag lets you verify that Vitest can load the configuration without treating the empty suite as a failure.
Before you run the check, do you expect Vitest to load the client configuration or reject it?
- Right-click the client folder in the Explorer sidebar.
- Select Open in Integrated Terminal.
- Start the client test runner by running this command:
npm test -- --passWithNoTests
Why Allow an Empty Test Run?
The --passWithNoTests flag lets this initial check exit successfully before test files exist. Vitest still reads vite.config.js plus the configured setup file.
The successful run proves the frontend test toolchain can start. Later interaction tests replace this empty collection with browser behavior checks.
Does the Test Runner Reject the Setup?
Confirm setupTests.js sits inside client/src. Check that its import matches the full-code tab.
Confirm the test environment in vite.config.js is named jsdom.
Help me diagnose the Vitest setup.
Your database plus API are running. Your React client now has a visible starting point plus a test-ready environment. Next, you will connect the browser to the existing cookie-backed authentication flow.
Connect the Cookie-Based Auth Flow
Your React client can reach the existing Express API through the Vite proxy. Protected chat routes still depend on a valid browser session.
This step connects the client to httpOnly cookies. You will also repair refresh-token rotation so a normal second refresh stays valid.
In this step, get ready to:
- Expose the current second-refresh failure with a backend contract test.
- Add credentialed API requests plus a controlled login form.
- Restore the browser session after a reload.
Prove the refresh failure
The current refresh controller sees an older revoked token in the family during a second rotation. It treats that normal history as token reuse.
A backend contract test captures the failure before you change the controller. This gives the repair a precise success condition.
- Return to the root project in the VS Code Explorer sidebar.
- Create __tests__/frontend-contracts.test.js inside the existing __tests__ folder.
- Add the test setup with this first section:
const request = require("supertest");
const { app, createTestUser, loginUser } = require("./setup");
function cookiePairs(setCookieHeaders = []) {
return setCookieHeaders.map((header) => header.split(";")[0]);
}
describe("Frontend contracts", () => {
let alice;
let sessionCookies;
beforeAll(async () => {
const suffix = `${Date.now()}`.slice(-8);
alice = {
email: `alice-${suffix}@test.com`,
username: `alice${suffix}`,
password: "Password123!",
};
await createTestUser(alice.email, alice.username, alice.password);
const login = await loginUser(alice.email, alice.password);
sessionCookies = login.cookies;
});
What Does This Setup Do?
- The timestamp suffix gives the test user a fresh email address plus username.
- The login helper stores the session cookies returned by the API.
- The cookiePairs() helper extracts the cookie values that later requests send back.
- Continue directly below the setup with this refresh contract test:
test("rotates the same refresh-token family twice", async () => {
const firstRefresh = await request(app)
.post("/api/auth/refresh")
.set("Cookie", cookiePairs(sessionCookies));
expect(firstRefresh.status).toBe(200);
const secondRefresh = await request(app)
.post("/api/auth/refresh")
.set("Cookie", cookiePairs(firstRefresh.headers["set-cookie"]));
expect(secondRefresh.status).toBe(200);
});
});
What Does This Test Prove?
- The first request rotates the original refresh token.
- The second request uses the replacement cookie returned by the first response.
- Both status assertions require a successful response from the same token family.
- Save __tests__/frontend-contracts.test.js.
✔️ Awesome, I've got everything!
Your new contract test now models two valid rotations in one session.
ⓧ I'd like to double check the full code
const request = require("supertest");
const { app, createTestUser, loginUser } = require("./setup");
function cookiePairs(setCookieHeaders = []) {
return setCookieHeaders.map((header) => header.split(";")[0]);
}
describe("Frontend contracts", () => {
let alice;
let sessionCookies;
beforeAll(async () => {
const suffix = `${Date.now()}`.slice(-8);
alice = {
email: `alice-${suffix}@test.com`,
username: `alice${suffix}`,
password: "Password123!",
};
await createTestUser(alice.email, alice.username, alice.password);
const login = await loginUser(alice.email, alice.password);
sessionCookies = login.cookies;
});
test("rotates the same refresh-token family twice", async () => {
const firstRefresh = await request(app)
.post("/api/auth/refresh")
.set("Cookie", cookiePairs(sessionCookies));
expect(firstRefresh.status).toBe(200);
const secondRefresh = await request(app)
.post("/api/auth/refresh")
.set("Cookie", cookiePairs(firstRefresh.headers["set-cookie"]));
expect(secondRefresh.status).toBe(200);
});
});
Before you run the contract test, do you think both refresh requests currently return a successful status?
- Return to the root terminal from earlier.
- Run the backend test suite with this command:
npm test
The new test fails at the second refresh assertion. This is the intended shortfall.
Why Does the Second Refresh Fail?
The first rotation revokes its stored token before creating the replacement. The next request finds that revoked family row before it identifies the replacement token.
The controller therefore reports token reuse during a valid session. Matching the presented token first removes that false result.
- Select src/controllers/authController.js in the VS Code Explorer sidebar.
- Find the exports.refresh controller.
- Locate the token-family query immediately below await client.query("BEGIN");.
- Replace the existing family lookup plus revoked-token check with this code:
const storedTokens = await client.query(
"SELECT * FROM refresh_tokens WHERE user_id = $1 AND family_id = $2 ORDER BY created_at DESC FOR UPDATE",
[decoded.id, decoded.family],
);
let matchedToken = null;
for (const storedToken of storedTokens.rows) {
if (await bcrypt.compare(refreshToken, storedToken.token_hash)) {
matchedToken = storedToken;
break;
}
}
if (!matchedToken) {
await client.query("ROLLBACK");
return res.status(401).json({
success: false,
error: { message: "Refresh token not recognized", code: "TOKEN_NOT_FOUND" },
});
}
if (matchedToken.is_revoked) {
await client.query("DELETE FROM refresh_tokens WHERE family_id = $1", [decoded.family]);
await client.query("COMMIT");
clearTokenCookies(res);
return res.status(401).json({
success: false,
error: { message: "Token reuse detected. Token family revoked.", code: "TOKEN_THEFT" },
});
}
How Does Matching Work?
- The query locks every stored token in the presented family for the duration of the transaction.
- The loop compares the presented cookie against each stored hash.
- A missing match returns a rejected request without deleting the token family.
- A revoked matching token triggers family revocation because that exact token has been replayed.
- Find the expiration check that follows the revoked-token check.
- Replace that expiration block with this code:
if (new Date(matchedToken.expires_at) <= new Date()) {
await client.query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE id = $1", [matchedToken.id]);
await client.query("COMMIT");
clearTokenCookies(res);
return res.status(401).json({
success: false,
error: { message: "Refresh token expired", code: "INVALID_TOKEN" },
});
}
Why Check the Matched Token?
The expiry decision now belongs to the token that matched the cookie. An expired match is revoked before the transaction completes.
- Find the update that revokes the active token before inserting its replacement.
- Make that update use the matched token ID:
await client.query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE id = $1", [matchedToken.id]);
What Changes During Rotation?
Only the active token that matched the browser cookie becomes revoked. The replacement keeps the existing family ID for future replay detection.
- Save src/controllers/authController.js.
✔️ Awesome, I've got everything!
Your refresh controller now evaluates the matching stored token before deciding whether the family was replayed.
ⓧ I'd like to double check the updated function
exports.refresh = async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({
success: false,
error: { message: "No refresh token provided", code: "NO_TOKEN" },
});
}
let decoded;
try {
decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
} catch {
return res.status(401).json({
success: false,
error: { message: "Invalid refresh token", code: "INVALID_TOKEN" },
});
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const storedTokens = await client.query(
"SELECT * FROM refresh_tokens WHERE user_id = $1 AND family_id = $2 ORDER BY created_at DESC FOR UPDATE",
[decoded.id, decoded.family],
);
let matchedToken = null;
for (const storedToken of storedTokens.rows) {
if (await bcrypt.compare(refreshToken, storedToken.token_hash)) {
matchedToken = storedToken;
break;
}
}
if (!matchedToken) {
await client.query("ROLLBACK");
return res.status(401).json({
success: false,
error: { message: "Refresh token not recognized", code: "TOKEN_NOT_FOUND" },
});
}
if (matchedToken.is_revoked) {
await client.query("DELETE FROM refresh_tokens WHERE family_id = $1", [decoded.family]);
await client.query("COMMIT");
clearTokenCookies(res);
return res.status(401).json({
success: false,
error: { message: "Token reuse detected. Token family revoked.", code: "TOKEN_THEFT" },
});
}
if (new Date(matchedToken.expires_at) <= new Date()) {
await client.query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE id = $1", [matchedToken.id]);
await client.query("COMMIT");
clearTokenCookies(res);
return res.status(401).json({
success: false,
error: { message: "Refresh token expired", code: "INVALID_TOKEN" },
});
}
const userResult = await client.query(
"SELECT id, email, username FROM users WHERE id = $1",
[decoded.id],
);
if (userResult.rows.length === 0) {
await client.query("ROLLBACK");
return res.status(401).json({
success: false,
error: { message: "User not found", code: "USER_NOT_FOUND" },
});
}
await client.query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE id = $1", [matchedToken.id]);
const user = userResult.rows[0];
const newAccessToken = generateAccessToken(user);
const newRefreshToken = generateRefreshToken(user, decoded.family);
const newRefreshTokenHash = await bcrypt.hash(newRefreshToken, 10);
await client.query(
"INSERT INTO refresh_tokens (user_id, token_hash, family_id, is_revoked, expires_at) VALUES ($1, $2, $3, FALSE, NOW() + INTERVAL '7 days')",
[user.id, newRefreshTokenHash, decoded.family],
);
await client.query("COMMIT");
setTokenCookies(res, newAccessToken, newRefreshToken);
return res.json({ success: true, message: "Token refreshed" });
} catch (error) {
await client.query("ROLLBACK").catch(() => {});
console.error("Refresh error:", error);
return res.status(500).json({
success: false,
error: { message: "Internal server error", code: "SERVER_ERROR" },
});
} finally {
client.release();
}
};
Before you rerun the suite, do you expect the second refresh assertion to pass now?
- Rerun the backend test suite from the root terminal with this command:
npm test
Both refresh requests now return successful responses. That is the hard part done: normal token rotation no longer looks like replay.
Does the Refresh Test Still Fail?
Confirm that every comparison uses storedToken.token_hash. Confirm that the expiry plus revocation updates use matchedToken.id.
Check that the family deletion remains inside the matchedToken.is_revoked branch.
Help me diagnose the consecutive refresh-token test.
Add credentialed browser auth
The browser receives access plus refresh cookies from the API. JavaScript only needs to include those cookies with each request.
A shared API wrapper centralizes that behavior. It also retries one protected request after a successful refresh.
- Select the client/src folder in the VS Code Explorer sidebar.
- Create api.js inside client/src.
- Add the credentialed request wrapper:
async function request(path, options = {}, allowRefresh = true) {
const headers = { ...options.headers };
if (options.body) headers["Content-Type"] = "application/json";
const response = await fetch(path, {
...options,
headers,
credentials: "include",
});
if (response.status === 401 && allowRefresh && path !== "/api/auth/refresh") {
const refreshResponse = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
if (refreshResponse.ok) return request(path, options, false);
}
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error?.message || "Request failed");
}
return payload.data;
}
How Does the Request Wrapper Work?
- The credentials option includes cookies without reading their values in React.
- A protected request that returns 401 triggers one refresh request.
- A successful refresh repeats the original request with allowRefresh set to false.
- The retry guard prevents an endless refresh loop.
- Continue below the wrapper with the API methods:
export const api = {
getMe: async () => (await request("/api/auth/me")).user,
login: async (credentials) =>
(
await request("/api/auth/login", {
method: "POST",
body: JSON.stringify(credentials),
})
).user,
logout: () => request("/api/auth/logout", { method: "POST" }),
refresh: () => request("/api/auth/refresh", { method: "POST" }, false),
listConversations: () => request("/api/conversations"),
listMessages: (conversationId) => request(`/api/messages/${conversationId}`),
sendMessage: (conversationId, content) =>
request(`/api/messages/${conversationId}`, {
method: "POST",
body: JSON.stringify({ content }),
}),
searchUsers: (search) =>
request(`/api/users?search=${encodeURIComponent(search)}`),
createDirectConversation: (userId) =>
request("/api/conversations", {
method: "POST",
body: JSON.stringify({ participantIds: [userId], isGroup: false }),
}),
};
What Does the API Object Provide?
The authentication methods restore a user plus create or end a session. The remaining methods give later chat features one consistent request path.
- Save client/src/api.js.
- Confirm that api.js now appears inside client/src in the Explorer sidebar.
✔️ Awesome, I've got everything!
Your API wrapper now sends browser cookies plus performs one guarded refresh retry.
ⓧ I'd like to double check the full code
async function request(path, options = {}, allowRefresh = true) {
const headers = { ...options.headers };
if (options.body) headers["Content-Type"] = "application/json";
const response = await fetch(path, {
...options,
headers,
credentials: "include",
});
if (response.status === 401 && allowRefresh && path !== "/api/auth/refresh") {
const refreshResponse = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
if (refreshResponse.ok) return request(path, options, false);
}
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error?.message || "Request failed");
}
return payload.data;
}
export const api = {
getMe: async () => (await request("/api/auth/me")).user,
login: async (credentials) =>
(
await request("/api/auth/login", {
method: "POST",
body: JSON.stringify(credentials),
})
).user,
logout: () => request("/api/auth/logout", { method: "POST" }),
refresh: () => request("/api/auth/refresh", { method: "POST" }, false),
listConversations: () => request("/api/conversations"),
listMessages: (conversationId) => request(`/api/messages/${conversationId}`),
sendMessage: (conversationId, content) =>
request(`/api/messages/${conversationId}`, {
method: "POST",
body: JSON.stringify({ content }),
}),
searchUsers: (search) =>
request(`/api/users?search=${encodeURIComponent(search)}`),
createDirectConversation: (userId) =>
request("/api/conversations", {
method: "POST",
body: JSON.stringify({ participantIds: [userId], isGroup: false }),
}),
};
A controlled form keeps each field value in React state. The submit handler can then disable the button plus display an API error without exposing cookie values.
- Create a components folder inside client/src.
- Create LoginForm.jsx inside client/src/components.
- Add the form state plus submit handler with this first section:
import { useState } from "react";
export default function LoginForm({ onLogin }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
setSubmitting(true);
setError("");
try {
await onLogin({ email, password });
} catch (loginError) {
setError(loginError.message);
} finally {
setSubmitting(false);
}
}
How Does the Login State Behave?
- The email plus password values follow what the learner types.
- The submitting state prevents repeated requests while login is running.
- A rejected request stores its message for the alert below the fields.
- Continue directly below the submit handler with the form interface:
return (
<main className="auth-shell">
<form className="auth-card" onSubmit={handleSubmit}>
<h1>Chat Client</h1>
<label>
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={submitting}>
{submitting ? "Logging in..." : "Log in"}
</button>
</form>
</main>
);
}
What Does the Form Render?
Each input updates its matching state value. The button reports submission progress while the alert provides an accessible error message.
- Save client/src/components/LoginForm.jsx.
- Confirm that LoginForm.jsx now appears inside the components folder in the Explorer sidebar.
✔️ Awesome, I've got everything!
Your controlled login form now collects credentials plus reports submission errors.
ⓧ I'd like to double check the full code
import { useState } from "react";
export default function LoginForm({ onLogin }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
setSubmitting(true);
setError("");
try {
await onLogin({ email, password });
} catch (loginError) {
setError(loginError.message);
} finally {
setSubmitting(false);
}
}
return (
<main className="auth-shell">
<form className="auth-card" onSubmit={handleSubmit}>
<h1>Chat Client</h1>
<label>
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={submitting}>
{submitting ? "Logging in..." : "Log in"}
</button>
</form>
</main>
);
}
Restore and verify the session
Session restoration asks the API who owns the current cookie-backed session. A successful response skips the login form after a browser reload.
- Create socket.js inside client/src.
- Add the shared Socket.IO client without starting its connection:
import { io } from "socket.io-client";
export const socket = io({ autoConnect: false });
Why Keep the Socket Disconnected?
The singleton gives later live-chat work one shared connection. Manual connection keeps it inactive until React has restored an authenticated user.
- Save client/src/socket.js.
- Select client/src/App.jsx in the VS Code Explorer sidebar.
- Replace the starter component with this first section:
import { useEffect, useState } from "react";
import { api } from "./api.js";
import { socket } from "./socket.js";
import LoginForm from "./components/LoginForm.jsx";
export default function App({ apiClient = api, socketClient = socket }) {
const [session, setSession] = useState({ loading: true, user: null });
useEffect(() => {
let cancelled = false;
apiClient
.getMe()
.then((user) => {
if (!cancelled) setSession({ loading: false, user });
})
.catch(() => {
if (!cancelled) setSession({ loading: false, user: null });
});
return () => {
cancelled = true;
};
}, [apiClient]);
How Does Session Restoration Work?
- The initial loading state holds the interface while apiClient.getMe() checks the session.
- A successful response stores the authenticated user.
- A rejected response stores an unauthenticated state.
- The cleanup flag prevents a completed request from updating an unmounted component.
- Continue directly below the effect with the login plus logout handlers:
async function handleLogin(credentials) {
const user = await apiClient.login(credentials);
setSession({ loading: false, user });
}
async function handleLogout() {
try {
await apiClient.logout();
} finally {
setSession({ loading: false, user: null });
}
}
What Do the Auth Handlers Change?
A successful login puts the returned user into session state. Logout clears the client session even when the request itself cannot complete.
- Finish App.jsx with the three session views:
if (session.loading) return <main className="loading-screen">Restoring session...</main>;
if (!session.user) return <LoginForm onLogin={handleLogin} />;
return (
<main className="app-shell">
<header className="app-header">
<span>Signed in as {session.user.username}</span>
<button type="button" onClick={handleLogout}>Log out</button>
</header>
</main>
);
}
Which View Does React Choose?
- The loading view appears while the session request is unresolved.
- The login form appears when no authenticated user exists.
- The authenticated shell displays the username plus logout control after login.
- Save client/src/App.jsx.
- Switch back to the browser at http://localhost:3000.
You will see the Chat Client login form when the browser has no active session. The page now has a real authentication state.
Does the Browser Show an Import Error?
Confirm that api.js plus socket.js sit directly inside client/src.
Confirm that LoginForm.jsx sits inside client/src/components.
Help me diagnose the React import error.
✔️ Awesome, I've got everything!
Your application now selects a loading view, login form, or authenticated shell from session state.
ⓧ I'd like to double check the full code
import { useEffect, useState } from "react";
import { api } from "./api.js";
import { socket } from "./socket.js";
import LoginForm from "./components/LoginForm.jsx";
export default function App({ apiClient = api, socketClient = socket }) {
const [session, setSession] = useState({ loading: true, user: null });
useEffect(() => {
let cancelled = false;
apiClient
.getMe()
.then((user) => {
if (!cancelled) setSession({ loading: false, user });
})
.catch(() => {
if (!cancelled) setSession({ loading: false, user: null });
});
return () => {
cancelled = true;
};
}, [apiClient]);
async function handleLogin(credentials) {
const user = await apiClient.login(credentials);
setSession({ loading: false, user });
}
async function handleLogout() {
try {
await apiClient.logout();
} finally {
setSession({ loading: false, user: null });
}
}
if (session.loading) return <main className="loading-screen">Restoring session...</main>;
if (!session.user) return <LoginForm onLogin={handleLogin} />;
return (
<main className="app-shell">
<header className="app-header">
<span>Signed in as {session.user.username}</span>
<button type="button" onClick={handleLogout}>Log out</button>
</header>
</main>
);
}
The authentication views need clear loading plus form layouts. These styles make the three session states easy to distinguish.
- Select client/src/styles.css in the VS Code Explorer sidebar.
- Replace the starter styles with this global section:
:root {
font-family: Inter, system-ui, sans-serif;
color: #182033;
background: #eef2f8;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
button { cursor: pointer; }
What Do the Global Styles Set?
The root selector establishes the page typography plus color palette. The shared controls inherit that typography for a consistent interface.
- Continue below the global styles with the loading plus authentication layouts:
.loading-screen,
.auth-shell {
min-height: 100vh;
display: grid;
place-items: center;
}
.auth-card {
width: min(420px, calc(100% - 32px));
padding: 32px;
display: grid;
gap: 18px;
background: white;
border-radius: 18px;
box-shadow: 0 18px 50px rgba(23, 35, 58, 0.12);
}
.auth-card label,
.search-form { display: grid; gap: 8px; }
input { border: 1px solid #cad3e2; border-radius: 10px; padding: 11px 12px; }
button { border: 0; border-radius: 10px; padding: 10px 14px; background: #315efb; color: white; }
button:disabled { cursor: not-allowed; opacity: 0.55; }
How Is the Login Form Styled?
The loading message plus form are centered in the viewport. The card gives the fields a compact layout with a visible disabled-button state.
- Finish the current authentication styles with the signed-in shell:
.app-shell { min-height: 100vh; display: grid; grid-template-rows: auto auto 1fr; }
.app-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #182033; color: white; }
.app-header button, .dialog-heading button { background: #e5eaf3; color: #182033; }
.global-error, [role="alert"] { color: #a21d2d; }
.global-error { margin: 8px 20px; }
What Changes After Login?
The authenticated header separates the signed-in identity from the logout control. Alerts use a contrasting color when a request fails.
- Save client/src/styles.css.
- Switch back to the browser at http://localhost:3000.
The login form now appears inside a centered white card. Its email plus password fields sit above the blue login button.
✔️ Awesome, I've got everything!
Your loading view plus authentication views now have their finished layout.
ⓧ I'd like to double check the full code
:root {
font-family: Inter, system-ui, sans-serif;
color: #182033;
background: #eef2f8;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
button { cursor: pointer; }
.loading-screen,
.auth-shell {
min-height: 100vh;
display: grid;
place-items: center;
}
.auth-card {
width: min(420px, calc(100% - 32px));
padding: 32px;
display: grid;
gap: 18px;
background: white;
border-radius: 18px;
box-shadow: 0 18px 50px rgba(23, 35, 58, 0.12);
}
.auth-card label,
.search-form { display: grid; gap: 8px; }
input { border: 1px solid #cad3e2; border-radius: 10px; padding: 11px 12px; }
button { border: 0; border-radius: 10px; padding: 10px 14px; background: #315efb; color: white; }
button:disabled { cursor: not-allowed; opacity: 0.55; }
.app-shell { min-height: 100vh; display: grid; grid-template-rows: auto auto 1fr; }
.app-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #182033; color: white; }
.app-header button, .dialog-heading button { background: #e5eaf3; color: #182033; }
.global-error, [role="alert"] { color: #a21d2d; }
.global-error { margin: 8px 20px; }
Before the final check, do you expect the same signed-in username to remain visible after a browser reload?
- Enter the email address for one registered test user in the Email field.
- Enter that user's password in the Password field.
- Click Log in.
You will see Signed in as followed by the account username. The authenticated shell confirms that the browser sent the login cookies successfully.
- Reload the page in your browser.
You will see the same signed-in username after the reload. Session restoration succeeded without React reading either token.
- Click Log out.
You will return to the Chat Client login form. The API has cleared the session cookies.
- Return to the root terminal from earlier.
- Run the backend contract suite one final time with this command:
npm test
The backend suite passes with two consecutive refresh rotations. Your browser can now log in, restore its session after reload, then log out cleanly.
Does Login Return to the Form?
Confirm that the Express API still runs on port 5000. Confirm that the Vite client still runs on port 3000.
Check the browser request response for the message returned by the API. A rejected login usually points to credentials that do not match a registered test user.
Help me diagnose the browser authentication flow.
Your cookie-backed authentication flow is working from login through restoration and logout. Next, you will turn the authenticated shell into a conversation list with message threads.
Render Conversations and Threads
Your authenticated React shell can identify the signed-in user. However, a chat client needs to turn that session into conversations you can open.
This step builds the first complete thread through the existing REST API. You will test the result with two accounts to discover where a request-based thread stops feeling live.
In this step, get ready to:
- Display existing conversations with labels based on their participants.
- Load messages from oldest to newest inside the selected thread.
- Send messages through REST from two browser sessions.
Build the conversation sidebar
A direct conversation has no useful title in the database. Its visible label comes from the participant whose ID differs from the signed-in user's ID.
- Return to Visual Studio Code from earlier.
- Select the client/src/components folder in the Explorer sidebar.
- Click the New File button in the Explorer toolbar.
- Enter ConversationSidebar.jsx as the file name.
- Add the conversation naming helper by pasting this code:
function getConversationName(conversation, currentUserId) {
if (conversation.is_group) return conversation.title || "Group chat";
return (
conversation.participants.find((participant) => participant.id !== currentUserId)
?.username || "Direct chat"
);
}
How Does the Sidebar Choose a Name?
- The group branch uses the saved title when one exists.
- The direct-chat branch finds the participant whose ID differs from currentUserId.
- The fallback labels keep the sidebar readable when participant data is incomplete.
- Add the sidebar shell directly below getConversationName() by pasting this code:
export default function ConversationSidebar({
conversations,
currentUserId,
activeId,
onSelect,
onNewChat,
}) {
return (
<aside className="sidebar">
<div className="sidebar-heading">
<h1>Chats</h1>
<button type="button" onClick={onNewChat}>New chat</button>
</div>
What Does the Sidebar Shell Do?
- The component receives conversation data plus the signed-in user's ID through props.
- The activeId prop identifies the selected conversation.
- The heading keeps the conversation list separate from the thread panel.
- Complete the sidebar directly below the heading container by pasting this code:
{conversations.length === 0 ? (
<p className="empty">No conversations yet.</p>
) : (
<ul className="conversation-list">
{conversations.map((conversation) => (
<li key={conversation.id}>
<button
type="button"
className={conversation.id === activeId ? "active" : ""}
aria-pressed={conversation.id === activeId}
onClick={() => onSelect(conversation.id)}
>
<strong>{getConversationName(conversation, currentUserId)}</strong>
<span>{conversation.last_message?.content || "No messages yet"}</span>
</button>
</li>
))}
</ul>
)}
</aside>
);
}
export { getConversationName };
How Does Selection Work?
- Each conversation uses its ID as a stable React key.
- The selected button receives the active class plus an updated accessibility state.
- Selecting a button passes that conversation's ID to onSelect.
- The preview displays the latest message when one exists.
- Save client/src/components/ConversationSidebar.jsx.
- Confirm ConversationSidebar.jsx appears beside LoginForm.jsx in the Explorer sidebar.
Seeing a Sidebar Syntax Error?
Confirm the second code section sits inside the component's returned aside element. Check that the final export remains below the component's closing brace.
Compare every opening parenthesis with its closing parenthesis. Help me check my conversation sidebar.
✔️ Awesome, I've got everything!
Your sidebar component can derive direct-chat labels plus track the selected conversation.
ⓧ I'd like to double check the full code
function getConversationName(conversation, currentUserId) {
if (conversation.is_group) return conversation.title || "Group chat";
return (
conversation.participants.find((participant) => participant.id !== currentUserId)
?.username || "Direct chat"
);
}
export default function ConversationSidebar({
conversations,
currentUserId,
activeId,
onSelect,
onNewChat,
}) {
return (
<aside className="sidebar">
<div className="sidebar-heading">
<h1>Chats</h1>
<button type="button" onClick={onNewChat}>New chat</button>
</div>
{conversations.length === 0 ? (
<p className="empty">No conversations yet.</p>
) : (
<ul className="conversation-list">
{conversations.map((conversation) => (
<li key={conversation.id}>
<button
type="button"
className={conversation.id === activeId ? "active" : ""}
aria-pressed={conversation.id === activeId}
onClick={() => onSelect(conversation.id)}
>
<strong>{getConversationName(conversation, currentUserId)}</strong>
<span>{conversation.last_message?.content || "No messages yet"}</span>
</button>
</li>
))}
</ul>
)}
</aside>
);
}
export { getConversationName };
Render the selected message thread
The backend returns recent messages in reverse chronological order. The client sorts that response before rendering so the conversation reads from oldest to newest.
The composer uses a controlled form. React state holds the input value while the submit handler trims the message plus reports sending errors.
- Select the client/src/components folder in the Explorer sidebar.
- Click the New File button in the Explorer toolbar.
- Enter ChatPanel.jsx as the file name.
- Add the component state plus its submit handler by pasting this code:
import { useState } from "react";
import { getConversationName } from "./ConversationSidebar.jsx";
export default function ChatPanel({ conversation, currentUser, messages, loading, onSend }) {
const [content, setContent] = useState("");
const [error, setError] = useState("");
const [sending, setSending] = useState(false);
if (!conversation) {
return <section className="chat-panel empty-panel">Choose a conversation.</section>;
}
async function handleSubmit(event) {
event.preventDefault();
if (!content.trim()) return;
setSending(true);
setError("");
try {
await onSend(content.trim());
setContent("");
} catch (sendError) {
setError(sendError.message);
} finally {
setSending(false);
}
}
How Does the Composer Protect Its State?
- The empty-state return asks the user to choose a conversation before composing a message.
- The trimmed-content check prevents blank messages from reaching the API.
- The sending state prevents repeated submissions while the request is active.
- A successful request clears the input while a failed request preserves the message for another attempt.
- Add the thread header plus message log directly below handleSubmit() by pasting this code:
return (
<section className="chat-panel">
<header className="thread-header">
<h2>{getConversationName(conversation, currentUser.id)}</h2>
</header>
<div className="messages" role="log" aria-live="polite">
{loading ? (
<p>Loading messages...</p>
) : messages.length === 0 ? (
<p className="empty">No messages yet. Say hello.</p>
) : (
messages.map((message) => (
<article
className={message.sender_id === currentUser.id ? "message mine" : "message"}
key={message.id}
>
<strong>{message.sender?.username || currentUser.username}</strong>
<p>{message.content}</p>
<time dateTime={message.created_at}>
{new Date(message.created_at).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</time>
</article>
))
)}
</div>
How Are Messages Rendered?
- The thread heading reuses the same conversation naming rule as the sidebar.
- The live-region attributes make newly rendered messages available to assistive technology.
- Messages from the signed-in user receive the message mine classes.
- Every message shows its sender plus its local display time.
- Complete the panel directly below the message log by pasting this code:
<form className="composer" onSubmit={handleSubmit}>
<label className="sr-only" htmlFor="message-content">Message</label>
<input
id="message-content"
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder="Write a message"
/>
<button type="submit" disabled={sending || !content.trim()}>Send</button>
</form>
{error && <p role="alert">{error}</p>}
</section>
);
}
What Completes the Thread?
- The input value always comes from content state.
- The input change handler stores each new value in that state.
- The send button remains unavailable while the message is empty or being submitted.
- The alert exposes any error returned by the REST request.
- Save client/src/components/ChatPanel.jsx.
- Confirm ChatPanel.jsx appears beside ConversationSidebar.jsx in the Explorer sidebar.
Seeing an Error in ChatPanel?
Confirm the returned JSX remains inside ChatPanel(). Check that the composer follows the closing tag for the message log.
Verify the helper import ends with ConversationSidebar.jsx. Help me check my ChatPanel component.
✔️ Awesome, I've got everything!
Your thread component can show loading states plus chronological message bubbles plus a controlled composer.
ⓧ I'd like to double check the full code
import { useState } from "react";
import { getConversationName } from "./ConversationSidebar.jsx";
export default function ChatPanel({ conversation, currentUser, messages, loading, onSend }) {
const [content, setContent] = useState("");
const [error, setError] = useState("");
const [sending, setSending] = useState(false);
if (!conversation) {
return <section className="chat-panel empty-panel">Choose a conversation.</section>;
}
async function handleSubmit(event) {
event.preventDefault();
if (!content.trim()) return;
setSending(true);
setError("");
try {
await onSend(content.trim());
setContent("");
} catch (sendError) {
setError(sendError.message);
} finally {
setSending(false);
}
}
return (
<section className="chat-panel">
<header className="thread-header">
<h2>{getConversationName(conversation, currentUser.id)}</h2>
</header>
<div className="messages" role="log" aria-live="polite">
{loading ? (
<p>Loading messages...</p>
) : messages.length === 0 ? (
<p className="empty">No messages yet. Say hello.</p>
) : (
messages.map((message) => (
<article
className={message.sender_id === currentUser.id ? "message mine" : "message"}
key={message.id}
>
<strong>{message.sender?.username || currentUser.username}</strong>
<p>{message.content}</p>
<time dateTime={message.created_at}>
{new Date(message.created_at).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</time>
</article>
))
)}
</div>
<form className="composer" onSubmit={handleSubmit}>
<label className="sr-only" htmlFor="message-content">Message</label>
<input
id="message-content"
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder="Write a message"
/>
<button type="submit" disabled={sending || !content.trim()}>Send</button>
</form>
{error && <p role="alert">{error}</p>}
</section>
);
}
Connect conversation state to REST
The authenticated shell now needs two related state layers. conversations holds the sidebar while messagesByConversation keeps each loaded thread under its conversation ID.
Sorting copies API results into the order required by the interface. The send path then reloads the active history so the submitted message appears from persisted data.
- Select client/src/App.jsx in the Explorer sidebar.
- Keep the existing session restoration plus login logic.
- Update the imports plus add the sorting helpers with this code:
import { useEffect, useState } from "react";
import { api } from "./api.js";
import LoginForm from "./components/LoginForm.jsx";
import ConversationSidebar from "./components/ConversationSidebar.jsx";
import ChatPanel from "./components/ChatPanel.jsx";
function byNewestConversation(a, b) {
return new Date(b.updated_at) - new Date(a.updated_at);
}
function byOldestMessage(a, b) {
return new Date(a.created_at) - new Date(b.created_at);
}
Why Use Two Sort Orders?
- The sidebar places recently updated conversations first.
- The thread places older messages before newer messages.
- The two comparison functions keep those ordering rules named plus reusable.
- Add the conversation state below the existing session state.
- Add the conversation-loading effect below the session-restoration effect by pasting this code:
const [conversations, setConversations] = useState([]);
const [activeId, setActiveId] = useState(null);
const [messagesByConversation, setMessagesByConversation] = useState({});
const [loadingMessages, setLoadingMessages] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!session.user) return undefined;
let cancelled = false;
apiClient
.listConversations()
.then((items) => {
if (!cancelled) setConversations(items.sort(byNewestConversation));
})
.catch((loadError) => {
if (!cancelled) setError(loadError.message);
});
return () => {
cancelled = true;
};
}, [session.user, apiClient]);
How Are Conversations Loaded?
- The effect waits until session restoration provides a signed-in user.
- The API response is sorted before it enters conversations state.
- The cleanup flag prevents a completed request from updating an abandoned component.
- Add the message-loading effect directly below the conversation-loading effect by pasting this code:
useEffect(() => {
if (!activeId) return undefined;
let cancelled = false;
setLoadingMessages(true);
apiClient
.listMessages(activeId)
.then((items) => {
if (!cancelled) {
setMessagesByConversation((current) => ({
...current,
[activeId]: [...items].sort(byOldestMessage),
}));
}
})
.catch((loadError) => {
if (!cancelled) setError(loadError.message);
})
.finally(() => {
if (!cancelled) setLoadingMessages(false);
});
return () => {
cancelled = true;
};
}, [activeId, apiClient]);
How Is Each Thread Stored?
- Changing activeId requests the selected conversation's history.
- The spread operation preserves message arrays already stored for other conversations.
- The selected response is copied before byOldestMessage sorts it.
- The loading state covers the full request lifecycle.
- Extend the existing logout cleanup with the first three state resets below.
- Add handleSend() below handleLogout() by pasting this code:
setConversations([]);
setMessagesByConversation({});
setActiveId(null);
async function handleSend(content) {
await apiClient.sendMessage(activeId, content);
const items = await apiClient.listMessages(activeId);
setMessagesByConversation((current) => ({
...current,
[activeId]: [...items].sort(byOldestMessage),
}));
}
Why Reload After Sending?
- Logout clears chat state that belongs to the previous user.
- The send request persists the message through the existing endpoint.
- The follow-up history request rebuilds the thread from the backend response.
- Sorting keeps the refreshed thread chronological.
- Replace the authenticated-shell return at the bottom of App() with this code:
if (session.loading) return <main className="loading-screen">Restoring session...</main>;
if (!session.user) return <LoginForm onLogin={handleLogin} />;
const activeConversation = conversations.find((item) => item.id === activeId);
return (
<main className="app-shell">
<header className="app-header">
<span>Signed in as {session.user.username}</span>
<button type="button" onClick={handleLogout}>Log out</button>
</header>
{error && <p className="global-error" role="alert">{error}</p>}
<div className="chat-layout">
<ConversationSidebar
conversations={conversations}
currentUserId={session.user.id}
activeId={activeId}
onSelect={setActiveId}
/>
<ChatPanel
conversation={activeConversation}
currentUser={session.user}
messages={messagesByConversation[activeId] || []}
loading={loadingMessages}
onSend={handleSend}
/>
</div>
</main>
);
}
How Does the Shell Join Everything Together?
- The sidebar receives the conversation list plus the state setter used for selection.
- The selected ID resolves to one complete conversation object.
- The panel receives the selected conversation plus its stored message array.
- The composer delegates each submission to handleSend().
- Save client/src/App.jsx.
- Switch back to the browser client from earlier.
- Refresh http://localhost:3000.
You should see a Chats sidebar beside a panel that says Choose a conversation..
- Select an existing conversation shared by your two test accounts.
You should see its messages ordered from oldest at the top to newest at the bottom. Messages from your account appear on the right with the sender-aware styling.
Does the Chat Layout Fail to Load?
Confirm the API from earlier still reports healthy on port 5000. Confirm the client still loads through port 3000.
Check that both component import paths begin with ./components/. Help me diagnose my conversation layout.
✔️ Awesome, I've got everything!
Your authenticated shell now loads conversations plus displays the selected message history.
ⓧ I'd like to double check the full code
import { useEffect, useState } from "react";
import { api } from "./api.js";
import LoginForm from "./components/LoginForm.jsx";
import ConversationSidebar from "./components/ConversationSidebar.jsx";
import ChatPanel from "./components/ChatPanel.jsx";
function byNewestConversation(a, b) {
return new Date(b.updated_at) - new Date(a.updated_at);
}
function byOldestMessage(a, b) {
return new Date(a.created_at) - new Date(b.created_at);
}
export default function App({ apiClient = api }) {
const [session, setSession] = useState({ loading: true, user: null });
const [conversations, setConversations] = useState([]);
const [activeId, setActiveId] = useState(null);
const [messagesByConversation, setMessagesByConversation] = useState({});
const [loadingMessages, setLoadingMessages] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
apiClient
.getMe()
.then((user) => {
if (!cancelled) setSession({ loading: false, user });
})
.catch(() => {
if (!cancelled) setSession({ loading: false, user: null });
});
return () => {
cancelled = true;
};
}, [apiClient]);
useEffect(() => {
if (!session.user) return undefined;
let cancelled = false;
apiClient
.listConversations()
.then((items) => {
if (!cancelled) setConversations(items.sort(byNewestConversation));
})
.catch((loadError) => {
if (!cancelled) setError(loadError.message);
});
return () => {
cancelled = true;
};
}, [session.user, apiClient]);
useEffect(() => {
if (!activeId) return undefined;
let cancelled = false;
setLoadingMessages(true);
apiClient
.listMessages(activeId)
.then((items) => {
if (!cancelled) {
setMessagesByConversation((current) => ({
...current,
[activeId]: [...items].sort(byOldestMessage),
}));
}
})
.catch((loadError) => {
if (!cancelled) setError(loadError.message);
})
.finally(() => {
if (!cancelled) setLoadingMessages(false);
});
return () => {
cancelled = true;
};
}, [activeId, apiClient]);
async function handleLogin(credentials) {
const user = await apiClient.login(credentials);
setSession({ loading: false, user });
}
async function handleLogout() {
try {
await apiClient.logout();
} finally {
setSession({ loading: false, user: null });
setConversations([]);
setMessagesByConversation({});
setActiveId(null);
}
}
async function handleSend(content) {
await apiClient.sendMessage(activeId, content);
const items = await apiClient.listMessages(activeId);
setMessagesByConversation((current) => ({
...current,
[activeId]: [...items].sort(byOldestMessage),
}));
}
if (session.loading) return <main className="loading-screen">Restoring session...</main>;
if (!session.user) return <LoginForm onLogin={handleLogin} />;
const activeConversation = conversations.find((item) => item.id === activeId);
return (
<main className="app-shell">
<header className="app-header">
<span>Signed in as {session.user.username}</span>
<button type="button" onClick={handleLogout}>Log out</button>
</header>
{error && <p className="global-error" role="alert">{error}</p>}
<div className="chat-layout">
<ConversationSidebar
conversations={conversations}
currentUserId={session.user.id}
activeId={activeId}
onSelect={setActiveId}
/>
<ChatPanel
conversation={activeConversation}
currentUser={session.user}
messages={messagesByConversation[activeId] || []}
loading={loadingMessages}
onSend={handleSend}
/>
</div>
</main>
);
}
Before you send a message, what do you expect the thread to do after the REST request succeeds?
- Enter First REST message in the message composer.
- Click Send.
You should see First REST message appear once at the bottom of the thread. The reloaded history proves the backend persisted it.
- Open a private browser window through your browser menu.
- Enter http://localhost:3000 in its address bar.
- Sign in with the second test account.
- Select the same existing conversation.
- Enter Second account REST message in the composer.
- Click Send.
The private window shows the new message at the bottom of its thread. Keep both browser sessions open for the comparison.
Before you return to the first browser window, do you think its open thread already contains the second account's message?
- Switch back to the first browser window.
- Inspect the open conversation without refreshing the page.
The message from the second account is absent. This intended shortfall proves that the current thread updates only after its own REST activity.
Before you refresh, where do you expect the second account's message to appear in the chronological thread?
- Refresh the first browser window.
- Select the same conversation again.
You should now see both new messages exactly once. The second account's message appears after the earlier message because byOldestMessage sorts the refreshed history chronologically.
Does the Refreshed Message Stay Missing?
Confirm both accounts selected the same conversation. Check that the private window showed Second account REST message before you refreshed the first window.
Sign in again if either session expired during the check. Help me trace the missing REST message.
That is a solid first chat loop. Your client can browse conversations plus persist messages, while the two-browser check has exposed the live-update gap for the next step.
Search for Users and Start Chats
Your React client can display existing conversations through the REST API. A signed-in user still needs a practical way to discover another account.
This step adds authenticated username search to the Express backend. It also makes direct-chat creation reuse an existing conversation for the same pair.
In this step, get ready to:
- Expose public username search through an authenticated route.
- Reuse an existing direct conversation for the same two users.
- Build a New Chat dialog with behavior-focused tests.
Expose searchable users and reusable direct chats
The search response needs only the public fields required by the dialog. The query excludes the signed-in user from their own results.
- In the Explorer sidebar in Visual Studio Code, create src/controllers/userController.js.
- Add the username search controller by copying this code:
const { pool } = require("../config/db");
exports.searchUsers = async (req, res) => {
const search = req.query.search?.trim();
if (!search || search.length < 2) {
return res.json({ success: true, data: [] });
}
try {
const result = await pool.query(
`SELECT id, username, avatar_url
FROM users
WHERE id <> $1 AND username ILIKE $2
ORDER BY username ASC
LIMIT 10`,
[req.user.id, `%${search}%`],
);
return res.json({ success: true, data: result.rows });
} catch (error) {
console.error("User search error:", error);
return res.status(500).json({
success: false,
error: { message: "Failed to search users" },
});
}
};
What Does This Controller Do?
- The two-character check avoids broad searches with little useful intent.
- The PostgreSQL query selects only id, username, and avatar_url.
- The id <> $1 condition removes the requester from the results.
- The result limit keeps each response focused on ten possible matches.
- Save src/controllers/userController.js.
- Confirm userController.js appears inside src/controllers in the Explorer sidebar.
Is the Search Controller Missing?
Check that userController.js sits inside src/controllers. Confirm that the filename uses the same capitalization as the import you add next.
Ask for help if the controller cannot load. Help me check the location and exports in my user search controller.
An Express router connects the authenticated request path to searchUsers. Authentication runs before the controller receives the requester ID.
- In the Explorer sidebar, create src/routes/users.js.
- Add the authenticated users router by copying this code:
const express = require("express");
const { authenticate } = require("../middleware/auth");
const { searchUsers } = require("../controllers/userController");
const router = express.Router();
router.get("/", authenticate, searchUsers);
module.exports = router;
How Does the Route Protect Search?
The router runs authenticate before searchUsers. The controller can therefore use req.user.id to exclude the requester.
- Save src/routes/users.js.
- Confirm users.js appears inside src/routes in the Explorer sidebar.
- In src/server.js, find the route imports.
- Add the users route import so that section matches this code:
const authRoutes = require("./routes/auth");
const conversationRoutes = require("./routes/conversations");
const messageRoutes = require("./routes/messages");
const userRoutes = require("./routes/users");
const { pool } = require("./config/db");
What Does This Import Provide?
The userRoutes import gives the server access to the authenticated router. The next change assigns its public API prefix.
- Find the existing app.use calls in src/server.js.
- Add the users route mount so that section matches this code:
app.use("/api/auth", authRoutes);
app.use("/api/conversations", conversationRoutes);
app.use("/api/messages", messageRoutes);
app.use("/api/users", userRoutes);
What Does the Route Mount Do?
The mount connects GET /api/users?search= to the users router. Requests still pass through the cookie-based authentication middleware.
- Save src/server.js.
- Confirm the API terminal from earlier reports that the server is running after the saved change.
✔️ Awesome, I've got everything!
Great. Your server now imports the users router and mounts it at /api/users.
ⓧ I'd like to double check the full code
require("dotenv").config();
const express = require("express");
const { createServer } = require("node:http");
const { Server } = require("socket.io");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const authRoutes = require("./routes/auth");
const conversationRoutes = require("./routes/conversations");
const messageRoutes = require("./routes/messages");
const userRoutes = require("./routes/users");
const { pool } = require("./config/db");
const { setupSocket } = require("./socket");
const app = express();
const httpServer = createServer(app);
const PORT = process.env.PORT || 5000;
const corsOptions = {
origin: process.env.CLIENT_URL || "http://localhost:3000",
credentials: true,
};
app.use(cors(corsOptions));
app.use(express.json());
app.use(cookieParser());
app.use(express.static("public"));
app.get("/api/health", (req, res) => {
res.json({ success: true, message: "Server is running" });
});
app.use("/api/auth", authRoutes);
app.use("/api/conversations", conversationRoutes);
app.use("/api/messages", messageRoutes);
app.use("/api/users", userRoutes);
const io = new Server(httpServer, { cors: corsOptions });
app.set("io", io);
setupSocket(io);
if (process.env.NODE_ENV !== "test") {
httpServer.listen(PORT, async () => {
try {
const client = await pool.connect();
console.log("Connected to PostgreSQL database");
client.release();
} catch (error) {
console.error("Error connecting to PostgreSQL database:", error);
}
console.log(`Server is running on port ${PORT}`);
});
}
module.exports = { app, httpServer, io };
A direct-chat request may describe a pair that already has a conversation. The controller needs to return that existing conversation before inserting another room.
- Select src/controllers/conversationController.js in the Explorer sidebar.
- Replace its contents using the complete reference in the second tab below.
✔️ I replaced the controller
Your conversation controller now hydrates each conversation before returning it to the client.
ⓧ I'd like to double check the full code
const { pool } = require("../config/db");
async function getConversation(queryable, conversationId) {
const result = await queryable.query(
`SELECT c.*,
COALESCE((
SELECT json_agg(json_build_object(
'id', u.id,
'username', u.username,
'avatar_url', u.avatar_url
) ORDER BY u.username)
FROM participants p
JOIN users u ON p.user_id = u.id
WHERE p.conversation_id = c.id
), '[]'::json) AS participants,
(
SELECT json_build_object(
'content', m.content,
'sender_id', m.sender_id,
'created_at', m.created_at
)
FROM messages m
WHERE m.conversation_id = c.id
ORDER BY m.created_at DESC
LIMIT 1
) AS last_message
FROM conversations c
WHERE c.id = $1`,
[conversationId],
);
return result.rows[0];
}
exports.createConversation = async (req, res) => {
const { title, participantIds, isGroup } = req.body || {};
const userId = req.user.id;
if (!Array.isArray(participantIds) || participantIds.length === 0) {
return res.status(400).json({
success: false,
error: { message: "participantIds must be a non-empty array" },
});
}
const allParticipants = [...new Set([userId, ...participantIds])];
if (!isGroup && allParticipants.length !== 2) {
return res.status(400).json({
success: false,
error: { message: "Direct conversations must have exactly 2 participants" },
});
}
try {
const existingUsers = await pool.query(
"SELECT id FROM users WHERE id = ANY($1::uuid[])",
[allParticipants],
);
if (existingUsers.rows.length !== allParticipants.length) {
return res.status(400).json({
success: false,
error: { message: "One or more participants do not exist" },
});
}
if (!isGroup) {
const existingDirect = await pool.query(
`SELECT c.id
FROM conversations c
JOIN participants p ON p.conversation_id = c.id
WHERE c.is_group = FALSE
GROUP BY c.id
HAVING COUNT(*) = 2
AND COUNT(*) FILTER (WHERE p.user_id = ANY($1::uuid[])) = 2
LIMIT 1`,
[allParticipants],
);
if (existingDirect.rows.length > 0) {
const conversation = await getConversation(pool, existingDirect.rows[0].id);
return res.json({ success: true, data: conversation });
}
}
const client = await pool.connect();
let conversationId;
try {
await client.query("BEGIN");
const conversationResult = await client.query(
"INSERT INTO conversations (title, is_group) VALUES ($1, $2) RETURNING id",
[isGroup ? title : null, Boolean(isGroup)],
);
conversationId = conversationResult.rows[0].id;
for (const participantId of allParticipants) {
await client.query(
"INSERT INTO participants (conversation_id, user_id) VALUES ($1, $2)",
[conversationId, participantId],
);
}
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
const conversation = await getConversation(pool, conversationId);
const io = req.app.get("io");
for (const participantId of allParticipants) {
const sockets = await io.in(`user:${participantId}`).fetchSockets();
sockets.forEach((socket) => socket.join(`conversation:${conversationId}`));
io.to(`user:${participantId}`).emit("conversation:created", conversation);
}
return res.status(201).json({ success: true, data: conversation });
} catch (error) {
console.error("Error creating conversation:", error);
return res.status(500).json({
success: false,
error: { message: "Failed to create conversation" },
});
}
};
exports.getUserConversations = async (req, res) => {
try {
const result = await pool.query(
`SELECT c.*,
COALESCE((
SELECT json_agg(json_build_object(
'id', u.id,
'username', u.username,
'avatar_url', u.avatar_url
) ORDER BY u.username)
FROM participants p2
JOIN users u ON p2.user_id = u.id
WHERE p2.conversation_id = c.id
), '[]'::json) AS participants,
(
SELECT json_build_object(
'content', m.content,
'sender_id', m.sender_id,
'created_at', m.created_at
)
FROM messages m
WHERE m.conversation_id = c.id
ORDER BY m.created_at DESC
LIMIT 1
) AS last_message
FROM conversations c
JOIN participants p ON c.id = p.conversation_id
WHERE p.user_id = $1
ORDER BY c.updated_at DESC`,
[req.user.id],
);
return res.json({ success: true, data: result.rows });
} catch (error) {
console.error("Error fetching conversations:", error);
return res.status(500).json({
success: false,
error: { message: "Failed to fetch conversations" },
});
}
};
How Does Direct-Chat Reuse Work?
- The getConversation helper builds the participant list plus the latest message required by the sidebar.
- The direct-conversation query looks for a room containing exactly the same two participant IDs.
- The transaction inserts the conversation plus its participants as one database operation.
- A genuinely new room emits conversation:created through Socket.IO after the transaction succeeds.
- Save src/controllers/conversationController.js.
- Return to the root project terminal from earlier.
Before you run the backend tests, do you expect two direct-chat requests for the same pair to return matching conversation IDs?
- Run the backend contract suite with this command:
npm test
What Does This Test Run Prove?
The contract suite searches for a registered username without exposing its email address. It creates the same direct chat twice.
The matching conversation ID proves the second request reused the existing two-person room.
The backend tests pass. The search result contains public profile fields while the repeated request returns the first conversation ID.
Do the Backend Tests Fail?
Confirm that src/server.js mounts /api/users. Check that the direct-chat query receives allParticipants.
Confirm that your local database contains the test schema from the existing project. Help me diagnose the failing username-search or direct-chat reuse test.
Build the New Chat flow
A controlled form keeps the search text plus request state inside the dialog. Selecting a result hands its user ID back to the app.
The existing apiClient already provides searchUsers plus createDirectConversation. The new dialog connects those methods to the visible workflow.
- In the Explorer sidebar, create client/src/components/NewChatDialog.jsx.
- Build the controlled search dialog using the complete reference in the second tab below.
✔️ I created the dialog
Your new component now manages search input, results, request states, and start-chat actions.
ⓧ I'd like to double check the full code
import { useState } from "react";
export default function NewChatDialog({ apiClient, onClose, onStart }) {
const [search, setSearch] = useState("");
const [results, setResults] = useState([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [startingId, setStartingId] = useState(null);
async function handleSearch(event) {
event.preventDefault();
if (search.trim().length < 2) return;
setLoading(true);
setError("");
try {
setResults(await apiClient.searchUsers(search.trim()));
} catch (searchError) {
setError(searchError.message);
} finally {
setLoading(false);
}
}
async function handleStart(userId) {
setStartingId(userId);
setError("");
try {
await onStart(userId);
} catch (startError) {
setError(startError.message);
setStartingId(null);
}
}
return (
<div className="dialog-backdrop">
<section className="dialog" role="dialog" aria-modal="true" aria-labelledby="new-chat-title">
<div className="dialog-heading">
<h2 id="new-chat-title">New chat</h2>
<button type="button" onClick={onClose} aria-label="Close new chat">Close</button>
</div>
<form onSubmit={handleSearch} className="search-form">
<label htmlFor="user-search">Search usernames</label>
<div>
<input
id="user-search"
value={search}
onChange={(event) => setSearch(event.target.value)}
minLength={2}
required
/>
<button type="submit" disabled={loading}>
{loading ? "Searching..." : "Search"}
</button>
</div>
</form>
{error && <p role="alert">{error}</p>}
{results.length === 0 && !loading ? (
<p className="empty">Search for another account.</p>
) : (
<ul className="search-results">
{results.map((result) => (
<li key={result.id}>
<span>{result.username}</span>
<button
type="button"
onClick={() => handleStart(result.id)}
disabled={startingId === result.id}
aria-label={`Start chat with ${result.username}`}
>
{startingId === result.id ? "Opening..." : "Start chat"}
</button>
</li>
))}
</ul>
)}
</section>
</div>
);
}
How Does the Dialog Manage Requests?
- The search handler requires at least two trimmed characters before calling the API.
- The loading state changes the search button label while the request is active.
- The starting user ID disables the selected result while its conversation opens.
- Any request failure appears through the dialog's alert region.
- Save client/src/components/NewChatDialog.jsx.
- Confirm NewChatDialog.jsx appears inside client/src/components in the Explorer sidebar.
The app owns the conversation list plus the active conversation ID. Its start-chat handler can therefore merge the returned conversation before selecting it.
- Select client/src/App.jsx in the Explorer sidebar.
- Update the app using the complete cumulative reference in the second tab below.
✔️ I connected the dialog
Your app now opens the dialog, merges its selected conversation, activates that conversation, and closes the dialog.
ⓧ I'd like to double check the full code
import { useEffect, useState } from "react";
import { api } from "./api.js";
import { socket } from "./socket.js";
import LoginForm from "./components/LoginForm.jsx";
import ConversationSidebar from "./components/ConversationSidebar.jsx";
import ChatPanel from "./components/ChatPanel.jsx";
import NewChatDialog from "./components/NewChatDialog.jsx";
function byNewestConversation(a, b) {
return new Date(b.updated_at) - new Date(a.updated_at);
}
function byOldestMessage(a, b) {
return new Date(a.created_at) - new Date(b.created_at);
}
function upsertConversation(conversations, conversation) {
return [conversation, ...conversations.filter((item) => item.id !== conversation.id)].sort(
byNewestConversation,
);
}
export default function App({ apiClient = api, socketClient = socket }) {
const [session, setSession] = useState({ loading: true, user: null });
const [conversations, setConversations] = useState([]);
const [activeId, setActiveId] = useState(null);
const [messagesByConversation, setMessagesByConversation] = useState({});
const [loadingMessages, setLoadingMessages] = useState(false);
const [showNewChat, setShowNewChat] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
apiClient
.getMe()
.then((user) => {
if (!cancelled) setSession({ loading: false, user });
})
.catch(() => {
if (!cancelled) setSession({ loading: false, user: null });
});
return () => {
cancelled = true;
};
}, [apiClient]);
useEffect(() => {
if (!session.user) return undefined;
let cancelled = false;
apiClient
.listConversations()
.then((items) => {
if (!cancelled) setConversations(items.sort(byNewestConversation));
})
.catch((loadError) => {
if (!cancelled) setError(loadError.message);
});
return () => {
cancelled = true;
};
}, [session.user, apiClient]);
useEffect(() => {
if (!activeId) return undefined;
let cancelled = false;
setLoadingMessages(true);
apiClient
.listMessages(activeId)
.then((items) => {
if (!cancelled) {
setMessagesByConversation((current) => ({
...current,
[activeId]: [...items].sort(byOldestMessage),
}));
}
})
.catch((loadError) => {
if (!cancelled) setError(loadError.message);
})
.finally(() => {
if (!cancelled) setLoadingMessages(false);
});
return () => {
cancelled = true;
};
}, [activeId, apiClient]);
async function handleLogin(credentials) {
const user = await apiClient.login(credentials);
setSession({ loading: false, user });
}
async function handleLogout() {
try {
await apiClient.logout();
} finally {
setSession({ loading: false, user: null });
setConversations([]);
setMessagesByConversation({});
setActiveId(null);
}
}
async function handleStartChat(userId) {
const conversation = await apiClient.createDirectConversation(userId);
setConversations((current) => upsertConversation(current, conversation));
setActiveId(conversation.id);
setShowNewChat(false);
}
async function handleSend(content) {
await apiClient.sendMessage(activeId, content);
const items = await apiClient.listMessages(activeId);
setMessagesByConversation((current) => ({
...current,
[activeId]: [...items].sort(byOldestMessage),
}));
}
if (session.loading) return <main className="loading-screen">Restoring session...</main>;
if (!session.user) return <LoginForm onLogin={handleLogin} />;
const activeConversation = conversations.find((item) => item.id === activeId);
return (
<main className="app-shell">
<header className="app-header">
<span>Signed in as {session.user.username}</span>
<button type="button" onClick={handleLogout}>Log out</button>
</header>
{error && <p className="global-error" role="alert">{error}</p>}
<div className="chat-layout">
<ConversationSidebar
conversations={conversations}
currentUserId={session.user.id}
activeId={activeId}
onSelect={setActiveId}
onNewChat={() => setShowNewChat(true)}
/>
<ChatPanel
conversation={activeConversation}
currentUser={session.user}
messages={messagesByConversation[activeId] || []}
loading={loadingMessages}
onSend={handleSend}
/>
</div>
{showNewChat && (
<NewChatDialog
apiClient={apiClient}
onClose={() => setShowNewChat(false)}
onStart={handleStartChat}
/>
)}
</main>
);
}
Why Use an Upsert?
The upsertConversation helper removes any conversation with the returned ID before inserting the latest payload. This keeps the sidebar free from duplicate entries.
The same handler works for a new room plus a reused room. Both outcomes select the returned ID.
- Save client/src/App.jsx.
- Switch back to the browser client from earlier.
- Click New chat in the conversation sidebar.
You will see the New chat dialog with a Search usernames field. The dialog also shows a prompt to search for another account.
Does the Dialog Stay Hidden?
Confirm that NewChatDialog is imported in client/src/App.jsx. Check that onNewChat sets showNewChat to true.
Confirm that the component filename matches the import exactly. Help me diagnose why the New Chat dialog does not open.
Test search and duplicate prevention
Behavior-focused tests interact with the interface through the same labels and buttons a user sees. React Testing Library drives those interactions while Vitest checks the API calls plus rendered result.
- In the Explorer sidebar, create client/src/App.test.jsx.
- Add the login plus New Chat interaction tests using the complete reference in the second tab below.
✔️ I added the interaction tests
Your client tests now cover login after failed restoration plus opening a direct chat from search.
ⓧ I'd like to double check the full code
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import App from "./App.jsx";
const currentUser = { id: "u1", username: "alice", email: "alice@test.com" };
const bob = { id: "u2", username: "bob", avatar_url: null };
const conversation = {
id: "c1",
is_group: false,
title: null,
created_at: "2026-08-28T10:00:00.000Z",
updated_at: "2026-08-28T10:00:00.000Z",
participants: [currentUser, bob],
last_message: null,
};
function createApi(overrides = {}) {
return {
getMe: vi.fn().mockResolvedValue(currentUser),
login: vi.fn().mockResolvedValue(currentUser),
logout: vi.fn().mockResolvedValue(undefined),
refresh: vi.fn().mockResolvedValue(undefined),
listConversations: vi.fn().mockResolvedValue([]),
listMessages: vi.fn().mockResolvedValue([]),
sendMessage: vi.fn().mockResolvedValue({}),
searchUsers: vi.fn().mockResolvedValue([bob]),
createDirectConversation: vi.fn().mockResolvedValue(conversation),
...overrides,
};
}
function createSocket() {
const handlers = new Map();
return {
connect: vi.fn(),
disconnect: vi.fn(),
on: vi.fn((event, handler) => {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event).add(handler);
}),
off: vi.fn((event, handler) => handlers.get(event)?.delete(handler)),
emitServer(event, payload) {
handlers.get(event)?.forEach((handler) => handler(payload));
},
};
}
test("logs in when session restoration fails", async () => {
const user = userEvent.setup();
const apiClient = createApi({
getMe: vi.fn().mockRejectedValue(new Error("No session")),
});
render(<App apiClient={apiClient} socketClient={createSocket()} />);
await user.type(await screen.findByLabelText("Email"), "alice@test.com");
await user.type(screen.getByLabelText("Password"), "Password123!");
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(await screen.findByRole("heading", { name: "Chats" })).toBeInTheDocument();
expect(apiClient.login).toHaveBeenCalledWith({
email: "alice@test.com",
password: "Password123!",
});
});
test("searches for a user and opens a direct chat", async () => {
const user = userEvent.setup();
const apiClient = createApi();
render(<App apiClient={apiClient} socketClient={createSocket()} />);
await user.click(await screen.findByRole("button", { name: "New chat" }));
await user.type(screen.getByLabelText("Search usernames"), "bob");
await user.click(screen.getByRole("button", { name: "Search" }));
await user.click(await screen.findByRole("button", { name: "Start chat with bob" }));
expect(apiClient.createDirectConversation).toHaveBeenCalledWith("u2");
expect(await screen.findByRole("heading", { name: "bob" })).toBeInTheDocument();
});
What Do These Tests Check?
The first test proves a failed session restoration still leaves the login flow usable. The second test searches for bob through the visible dialog.
The final assertions prove the selected user ID reaches the conversation API. They also prove the returned conversation becomes the active thread.
- Save client/src/App.test.jsx.
- Return to the client terminal from earlier.
Before you run the client tests, do you expect the New Chat test to finish on the conversation heading or remain inside the dialog?
- Run the client interaction suite with this command:
npm test
What Does the Client Test Prove?
The test opens the dialog through the sidebar control. It searches by username before selecting the matching user.
The final heading proves the app selected the returned conversation. The dialog closes as part of the same flow.
The client tests pass. Your search interaction now has automated coverage from the first click to the selected thread.
Does the New Chat Test Fail?
Confirm that the dialog label is Search usernames. Check that its result button uses the selected username in the accessible label.
Confirm that handleStartChat sets activeId from the returned conversation. Help me diagnose the failing New Chat interaction test.
The final browser check confirms that the tested contract also works with your two registered accounts. The second request should reopen the same thread.
- Switch back to the browser client from earlier.
- Close the New chat dialog if it is still open.
- Click New chat.
- Enter your second test username in the Search usernames field.
- Click Search.
- Click Start chat beside the matching username.
You will see the selected username as the thread heading. The sidebar contains one direct-chat entry for that user.
Before you repeat the search, do you expect the sidebar to gain a second room for the same user?
- Click New chat again.
- Enter your second test username in the Search usernames field.
- Click Search.
- Click Start chat beside the same username.
The same thread opens again. The backend contract test confirms both requests returned the same conversation ID.
That is the duplicate-safe flow working end to end. Your sidebar still shows one direct chat for the pair.
Your users can now find each other without knowing account IDs. Next, you will connect the existing socket client so new conversations and messages arrive without a refresh.
Make Every Thread Live and Test It
Your New Chat flow now searches public usernames. It also reopens an existing direct conversation without adding a duplicate.
Incoming messages still wait for a page refresh. One authenticated Socket.IO lifecycle closes that gap while keeping every event listener under control.
In this step, get ready to:
- Connect one authenticated socket lifecycle for each signed-in App.
- Merge live conversations and messages into client state by ID.
- Prove each live message appears once with automated tests and two browser sessions.
Connect one authenticated socket lifecycle
A React Effect keeps an external connection synchronized with the signed-in session. Its cleanup removes the same named listeners before disconnecting the socket.
Why One Lifecycle?
A socket can remain active across many renders. Registering listeners during each render would leave older callbacks ready to process the same event.
One Effect owns the connection from authentication through cleanup. Named callbacks give every listener an exact removal target.
- Select client/src/App.jsx in the VS Code Explorer sidebar.
- Find the existing api import near the top of the file.
- Add the socket import directly below it:
import { socket } from "./socket.js";
What Does This Import Provide?
The imported socket is the client singleton configured earlier with autoConnect: false. The App decides when authentication is ready for a connection.
- Find the declaration that begins with export default function App.
- Replace the whole declaration with this line:
export default function App({ apiClient = api, socketClient = socket }) {
Why Inject the Socket Client?
The browser uses the real socket by default. Automated tests can supply a controlled fake through the socketClient prop.
- Save client/src/App.jsx.
- Switch back to the authenticated browser client from earlier.
- Refresh the page.
You should remain signed in. The conversation sidebar should still load without a global error.
Does the App Fail After the Import?
Confirm socket.js remains inside client/src. Check that the import includes the .js extension.
Confirm the App declaration uses socketClient = socket. A different prop name prevents the lifecycle from receiving the client.
Help me debug the socket import or App prop.
- Find upsertConversation() near the top of client/src/App.jsx.
- Add this message helper directly below it:
function upsertMessage(messages = [], message) {
return [...messages.filter((item) => item.id !== message.id), message].sort(byOldestMessage);
}
What Does the Message Helper Do?
The helper filters out any message with the incoming ID. It adds the latest payload before restoring chronological order.
Repeated delivery of the same message ID still produces one state entry. That ID check protects the thread from duplicate bubbles.
- Find the Effect that calls apiClient.listMessages(activeId).
- Add this socket Effect directly below that message-loading Effect:
useEffect(() => {
if (!session.user) return undefined;
let cancelled = false;
function onConversationCreated(conversation) {
setConversations((current) => upsertConversation(current, conversation));
}
socketClient.on("conversation:created", onConversationCreated);
socketClient.connect();
return () => {
cancelled = true;
socketClient.off("conversation:created", onConversationCreated);
socketClient.disconnect();
};
}, [session.user, apiClient, socketClient]);
How Does the Lifecycle Work?
- The signed-in user gates the Effect. Logged-out screens never connect the socket.
- The named onConversationCreated callback merges a new conversation through upsertConversation().
- Cleanup passes the same callback to off. It disconnects the socket when the authenticated lifecycle ends.
- Save client/src/App.jsx.
- Refresh the authenticated browser client.
- Inspect the browser's network requests.
- Confirm the requests include /socket.io after the signed-in App loads.
Good. The signed-in App now starts its socket connection without disturbing the existing chat interface.
Is the Socket Request Missing?
Confirm the Effect appears inside App. Check that it follows the existing message-loading Effect.
Confirm the browser restored a signed-in user. The Effect returns before connecting when session.user is empty.
Help me debug the missing socket connection.
Merge live events and recover authentication
A live message changes two views. The thread gains a chronologically sorted message while the sidebar gains a newer preview.
- Find onConversationCreated inside the socket Effect.
- Add this named message listener directly below it:
function onMessageNew(message) {
setMessagesByConversation((current) => ({
...current,
[message.conversation_id]: upsertMessage(
current[message.conversation_id],
message,
),
}));
setConversations((current) =>
current
.map((conversation) =>
conversation.id === message.conversation_id
? {
...conversation,
updated_at: message.created_at,
last_message: {
content: message.content,
sender_id: message.sender_id,
created_at: message.created_at,
},
}
: conversation,
)
.sort(byNewestConversation),
);
}
What Does the Message Listener Update?
- The first state update selects the thread using message.conversation_id. It merges the event through upsertMessage().
- The second state update replaces the matching conversation preview. It also moves that conversation according to its latest timestamp.
- Both updates create new arrays and objects. React can render the changed views immediately.
- Find the listener registration that begins with socketClient.on("conversation:created".
- Select that registration section through the Effect dependency line.
- Replace the selected section with this version:
socketClient.on("conversation:created", onConversationCreated);
socketClient.on("message:new", onMessageNew);
socketClient.connect();
return () => {
cancelled = true;
socketClient.off("conversation:created", onConversationCreated);
socketClient.off("message:new", onMessageNew);
socketClient.disconnect();
};
}, [session.user, apiClient, socketClient]);
Why Match Each On and Off Call?
Each on call registers one named callback. Cleanup supplies the same event name and callback to off.
That symmetry prevents an old listener from surviving a remount. One server event reaches one active callback.
Before you send the message, decide whether the receiving thread should show one copy or several.
- Save client/src/App.jsx.
- Switch back to the two authenticated browser sessions from earlier.
- Select the same conversation in both sessions.
- Enter Live message check in the sender's message field.
- Click Send.
- Check the receiving session without refreshing it.
You should see Live message check once in the receiving thread. The sidebar preview should change to the same text.
Is the Message Missing or Duplicated?
Confirm both registration and cleanup use message:new. Confirm both calls reference onMessageNew.
Check that upsertMessage() filters by message.id. That comparison keeps a repeated payload from adding a second bubble.
Help me debug the live message event.
An expired access cookie can deny the socket connection. The client needs one credentialed refresh before it manually attempts the connection again.
- Find onMessageNew inside the socket Effect.
- Add this connection-error handler directly below it:
async function onConnectError(connectionError) {
if (connectionError.message !== "Authentication error") {
setError(connectionError.message);
return;
}
try {
await apiClient.refresh();
if (!cancelled) socketClient.connect();
} catch {
if (!cancelled) setSession({ loading: false, user: null });
}
}
How Does the Retry Stay Controlled?
- A connection error with another message becomes visible through the existing global error state.
- The exact Authentication error denial triggers the existing credentialed refresh request.
- The client calls connect() again only after that refresh succeeds.
- A failed refresh returns the App to its logged-out state.
- Find the listener registration section at the bottom of the socket Effect.
- Replace that section through the Effect dependency line with this final version:
socketClient.on("conversation:created", onConversationCreated);
socketClient.on("message:new", onMessageNew);
socketClient.on("connect_error", onConnectError);
socketClient.connect();
return () => {
cancelled = true;
socketClient.off("conversation:created", onConversationCreated);
socketClient.off("message:new", onMessageNew);
socketClient.off("connect_error", onConnectError);
socketClient.disconnect();
};
}, [session.user, apiClient, socketClient]);
What Completes the Lifecycle?
The Effect now owns three named listeners and one connection. Its cleanup removes all three exact callbacks before disconnecting.
Authentication changes rebuild one complete lifecycle. Ordinary state updates keep the existing lifecycle in place.
Before you test the cleanup, decide how many copies should appear after one browser session reconnects.
- Save client/src/App.jsx.
- Refresh the receiving browser session.
- Select the shared conversation in the refreshed session.
- Enter Listener cleanup check in the other session.
- Click Send.
- Check the refreshed session without reloading it again.
You should see Listener cleanup check exactly once. The refreshed App has one current listener for the incoming event.
Does Refreshing Create Duplicates?
Confirm every on call has a matching off call. Each pair must use the same named function.
Confirm cleanup calls disconnect(). The next mounted lifecycle can then create the current connection cleanly.
Help me debug duplicate listeners after refresh.
- Compare the completed client/src/App.jsx file with the reference below.
✔️ Awesome, I've got everything!
Your App now owns one authenticated socket lifecycle. Live conversations and messages merge into state by ID.
ⓧ I'd like to double check the full code
import { useEffect, useState } from "react";
import { api } from "./api.js";
import { socket } from "./socket.js";
import LoginForm from "./components/LoginForm.jsx";
import ConversationSidebar from "./components/ConversationSidebar.jsx";
import ChatPanel from "./components/ChatPanel.jsx";
import NewChatDialog from "./components/NewChatDialog.jsx";
function byNewestConversation(a, b) {
return new Date(b.updated_at) - new Date(a.updated_at);
}
function byOldestMessage(a, b) {
return new Date(a.created_at) - new Date(b.created_at);
}
function upsertConversation(conversations, conversation) {
return [conversation, ...conversations.filter((item) => item.id !== conversation.id)].sort(
byNewestConversation,
);
}
function upsertMessage(messages = [], message) {
return [...messages.filter((item) => item.id !== message.id), message].sort(byOldestMessage);
}
export default function App({ apiClient = api, socketClient = socket }) {
const [session, setSession] = useState({ loading: true, user: null });
const [conversations, setConversations] = useState([]);
const [activeId, setActiveId] = useState(null);
const [messagesByConversation, setMessagesByConversation] = useState({});
const [loadingMessages, setLoadingMessages] = useState(false);
const [showNewChat, setShowNewChat] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
apiClient
.getMe()
.then((user) => {
if (!cancelled) setSession({ loading: false, user });
})
.catch(() => {
if (!cancelled) setSession({ loading: false, user: null });
});
return () => {
cancelled = true;
};
}, [apiClient]);
useEffect(() => {
if (!session.user) return undefined;
let cancelled = false;
apiClient
.listConversations()
.then((items) => {
if (!cancelled) setConversations(items.sort(byNewestConversation));
})
.catch((loadError) => {
if (!cancelled) setError(loadError.message);
});
return () => {
cancelled = true;
};
}, [session.user, apiClient]);
useEffect(() => {
if (!activeId) return undefined;
let cancelled = false;
setLoadingMessages(true);
apiClient
.listMessages(activeId)
.then((items) => {
if (!cancelled) {
setMessagesByConversation((current) => ({
...current,
[activeId]: [...items].sort(byOldestMessage),
}));
}
})
.catch((loadError) => {
if (!cancelled) setError(loadError.message);
})
.finally(() => {
if (!cancelled) setLoadingMessages(false);
});
return () => {
cancelled = true;
};
}, [activeId, apiClient]);
useEffect(() => {
if (!session.user) return undefined;
let cancelled = false;
function onConversationCreated(conversation) {
setConversations((current) => upsertConversation(current, conversation));
}
function onMessageNew(message) {
setMessagesByConversation((current) => ({
...current,
[message.conversation_id]: upsertMessage(
current[message.conversation_id],
message,
),
}));
setConversations((current) =>
current
.map((conversation) =>
conversation.id === message.conversation_id
? {
...conversation,
updated_at: message.created_at,
last_message: {
content: message.content,
sender_id: message.sender_id,
created_at: message.created_at,
},
}
: conversation,
)
.sort(byNewestConversation),
);
}
async function onConnectError(connectionError) {
if (connectionError.message !== "Authentication error") {
setError(connectionError.message);
return;
}
try {
await apiClient.refresh();
if (!cancelled) socketClient.connect();
} catch {
if (!cancelled) setSession({ loading: false, user: null });
}
}
socketClient.on("conversation:created", onConversationCreated);
socketClient.on("message:new", onMessageNew);
socketClient.on("connect_error", onConnectError);
socketClient.connect();
return () => {
cancelled = true;
socketClient.off("conversation:created", onConversationCreated);
socketClient.off("message:new", onMessageNew);
socketClient.off("connect_error", onConnectError);
socketClient.disconnect();
};
}, [session.user, apiClient, socketClient]);
async function handleLogin(credentials) {
const user = await apiClient.login(credentials);
setSession({ loading: false, user });
}
async function handleLogout() {
try {
await apiClient.logout();
} finally {
setSession({ loading: false, user: null });
setConversations([]);
setMessagesByConversation({});
setActiveId(null);
}
}
async function handleStartChat(userId) {
const conversation = await apiClient.createDirectConversation(userId);
setConversations((current) => upsertConversation(current, conversation));
setActiveId(conversation.id);
setShowNewChat(false);
}
async function handleSend(content) {
await apiClient.sendMessage(activeId, content);
const items = await apiClient.listMessages(activeId);
setMessagesByConversation((current) => ({
...current,
[activeId]: [...items].sort(byOldestMessage),
}));
}
if (session.loading) return <main className="loading-screen">Restoring session...</main>;
if (!session.user) return <LoginForm onLogin={handleLogin} />;
const activeConversation = conversations.find((item) => item.id === activeId);
return (
<main className="app-shell">
<header className="app-header">
<span>Signed in as {session.user.username}</span>
<button type="button" onClick={handleLogout}>Log out</button>
</header>
{error && <p className="global-error" role="alert">{error}</p>}
<div className="chat-layout">
<ConversationSidebar
conversations={conversations}
currentUserId={session.user.id}
activeId={activeId}
onSelect={setActiveId}
onNewChat={() => setShowNewChat(true)}
/>
<ChatPanel
conversation={activeConversation}
currentUser={session.user}
messages={messagesByConversation[activeId] || []}
loading={loadingMessages}
onSend={handleSend}
/>
</div>
{showNewChat && (
<NewChatDialog
apiClient={apiClient}
onClose={() => setShowNewChat(false)}
onStart={handleStartChat}
/>
)}
</main>
);
}
How to Use This Reference
Compare the imports and helper functions first. Confirm the socket Effect appears after the message-loading Effect.
Check that each on call has a matching off call using the same named callback.
Prove every message arrives once
A fake socket gives React Testing Library direct control over server events. The test can emit one incoming message before checking the rendered count.
Each existing Vitest test also needs an isolated socket. This prevents an authenticated test render from touching the browser singleton.
- Select client/src/App.test.jsx in the VS Code Explorer sidebar.
- Find the closing brace for createApi().
- Add this fake socket helper directly below it:
function createSocket() {
const handlers = new Map();
return {
connect: vi.fn(),
disconnect: vi.fn(),
on: vi.fn((event, handler) => {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event).add(handler);
}),
off: vi.fn((event, handler) => handlers.get(event)?.delete(handler)),
emitServer(event, payload) {
handlers.get(event)?.forEach((handler) => handler(payload));
},
};
}
How Does the Fake Socket Work?
- The handlers map stores a set of callbacks for each event name.
- The fake on and off methods mimic exact listener registration and cleanup.
- The emitServer() helper invokes the stored callbacks as if the server delivered an event.
- Find the render() call in each existing test.
- Replace each call with this line:
render(<App apiClient={apiClient} socketClient={createSocket()} />);
Why Inject a Fresh Socket?
Each test receives its own handler map. The App can connect and clean up without sharing listeners with another test.
Before you run the current suite, decide whether the two existing interactions should still pass with isolated sockets.
- Save client/src/App.test.jsx.
- Switch back to the terminal running inside client.
- Run the current client tests with the fake sockets:
npm test
What Does This Test Run Check?
The client test script runs the current interaction suite once. The login and New Chat tests now use separate fake socket clients.
You should see both existing tests pass. Their authenticated App instances no longer depend on a real socket connection.
Do the Existing Tests Fail?
Confirm both existing render() calls receive socketClient={createSocket()}.
Check that createSocket() appears before the tests. Its methods rely on the existing vi import.
Help me debug the fake socket test setup.
- Find the first import in client/src/App.test.jsx.
- Replace that import with this line:
import { act, render, screen } from "@testing-library/react";
Why Add Act?
The fake socket triggers state updates outside a normal user interaction. The act wrapper lets React finish those updates before the assertions run.
- Move to the bottom of client/src/App.test.jsx.
- Add this incoming-message interaction test:
test("renders an incoming Socket.IO message exactly once", async () => {
const user = userEvent.setup();
const socketClient = createSocket();
const apiClient = createApi({
listConversations: vi.fn().mockResolvedValue([conversation]),
});
render(<App apiClient={apiClient} socketClient={socketClient} />);
await user.click(await screen.findByRole("button", { name: /bob/i }));
act(() => {
socketClient.emitServer("message:new", {
id: "m1",
conversation_id: "c1",
sender_id: "u2",
content: "Hello in real time",
created_at: "2026-08-28T10:01:00.000Z",
sender: bob,
});
});
expect(await screen.findByText("Hello in real time")).toBeInTheDocument();
expect(screen.getAllByText("Hello in real time")).toHaveLength(1);
});
What Does This Test Prove?
- The test selects Bob's conversation before emitting message:new through the fake socket.
- The first assertion proves the incoming content reaches the active thread.
- The final assertion proves the message appears exactly once.
- Save client/src/App.test.jsx.
- Compare the completed test file with the reference below.
✔️ Awesome, I've got everything!
Your tests now isolate socket listeners. The new interaction proves one incoming event produces one visible message.
ⓧ I'd like to double check the full code
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import App from "./App.jsx";
const currentUser = { id: "u1", username: "alice", email: "alice@test.com" };
const bob = { id: "u2", username: "bob", avatar_url: null };
const conversation = {
id: "c1",
is_group: false,
title: null,
created_at: "2026-08-28T10:00:00.000Z",
updated_at: "2026-08-28T10:00:00.000Z",
participants: [currentUser, bob],
last_message: null,
};
function createApi(overrides = {}) {
return {
getMe: vi.fn().mockResolvedValue(currentUser),
login: vi.fn().mockResolvedValue(currentUser),
logout: vi.fn().mockResolvedValue(undefined),
refresh: vi.fn().mockResolvedValue(undefined),
listConversations: vi.fn().mockResolvedValue([]),
listMessages: vi.fn().mockResolvedValue([]),
sendMessage: vi.fn().mockResolvedValue({}),
searchUsers: vi.fn().mockResolvedValue([bob]),
createDirectConversation: vi.fn().mockResolvedValue(conversation),
...overrides,
};
}
function createSocket() {
const handlers = new Map();
return {
connect: vi.fn(),
disconnect: vi.fn(),
on: vi.fn((event, handler) => {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event).add(handler);
}),
off: vi.fn((event, handler) => handlers.get(event)?.delete(handler)),
emitServer(event, payload) {
handlers.get(event)?.forEach((handler) => handler(payload));
},
};
}
test("logs in when session restoration fails", async () => {
const user = userEvent.setup();
const apiClient = createApi({
getMe: vi.fn().mockRejectedValue(new Error("No session")),
});
render(<App apiClient={apiClient} socketClient={createSocket()} />);
await user.type(await screen.findByLabelText("Email"), "alice@test.com");
await user.type(screen.getByLabelText("Password"), "Password123!");
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(await screen.findByRole("heading", { name: "Chats" })).toBeInTheDocument();
expect(apiClient.login).toHaveBeenCalledWith({
email: "alice@test.com",
password: "Password123!",
});
});
test("searches for a user and opens a direct chat", async () => {
const user = userEvent.setup();
const apiClient = createApi();
render(<App apiClient={apiClient} socketClient={createSocket()} />);
await user.click(await screen.findByRole("button", { name: "New chat" }));
await user.type(screen.getByLabelText("Search usernames"), "bob");
await user.click(screen.getByRole("button", { name: "Search" }));
await user.click(await screen.findByRole("button", { name: "Start chat with bob" }));
expect(apiClient.createDirectConversation).toHaveBeenCalledWith("u2");
expect(await screen.findByRole("heading", { name: "bob" })).toBeInTheDocument();
});
test("renders an incoming Socket.IO message exactly once", async () => {
const user = userEvent.setup();
const socketClient = createSocket();
const apiClient = createApi({
listConversations: vi.fn().mockResolvedValue([conversation]),
});
render(<App apiClient={apiClient} socketClient={socketClient} />);
await user.click(await screen.findByRole("button", { name: /bob/i }));
act(() => {
socketClient.emitServer("message:new", {
id: "m1",
conversation_id: "c1",
sender_id: "u2",
content: "Hello in real time",
created_at: "2026-08-28T10:01:00.000Z",
sender: bob,
});
});
expect(await screen.findByText("Hello in real time")).toBeInTheDocument();
expect(screen.getAllByText("Hello in real time")).toHaveLength(1);
});
How to Use This Reference
Confirm the fake socket appears before the tests. Check that every App render receives an isolated socketClient.
The final test must emit one message:new payload. Its last assertion must expect one matching element.
Before you run both suites, decide whether the backend contracts and all three client interactions should pass together.
- Use a separate VS Code terminal in the folder that contains src/server.js.
- Run the root test suite:
npm test
What Does the Root Suite Check?
The root suite checks the backend contracts that support the client. Those contracts include repeated refresh rotation and duplicate-safe direct conversations.
You should see the root test suite pass. The frontend contracts remain compatible with the live client.
Do the Backend Tests Fail?
Confirm the PostgreSQL container from earlier is still running. The contract tests need the existing local database service.
Confirm you ran the command from the folder containing the backend package.json. Running it inside client starts a different suite.
Help me debug the backend contract suite.
- Switch back to the terminal running inside client.
- Run the complete client interaction suite:
npm test
What Does the Client Suite Check?
The suite checks login after failed restoration. It also checks username search and direct-chat opening.
The new test checks that one incoming socket event renders one message. Together they protect the browser behavior built across the project.
You should see three passing client tests. The automated checks now cover login, New Chat, and one deduplicated live message.
Does the Live Message Test Fail?
Confirm emitServer() uses message:new. Check that its conversation_id matches the selected conversation.
If the message appears twice, inspect the ID filter inside upsertMessage(). If it stays hidden, confirm the test selects Bob's conversation before emitting the event.
Help me debug the live message interaction test.
Before the final browser check, decide whether a new conversation and one sent message should reach the other session without any refresh.
- Switch back to two authenticated browser sessions.
- Use accounts that do not already share a direct conversation for the conversation check.
- Click New chat in the first session.
- Search for the account signed in to the second session.
- Click Start chat beside that account.
- Check the second session without refreshing it.
The new conversation should appear once in the recipient's sidebar. Both sessions should open the same direct conversation ID.
What Does the New Conversation Prove?
The server adds connected participant sockets to the new conversation room. It emits conversation:created through each participant's user room.
The client merges that hydrated conversation through upsertConversation(). The recipient sees the new chat without reloading.
- Select the new conversation in both sessions.
- Enter Exactly once live check in the first session.
- Click Send.
- Check the message thread in each session without refreshing.
- Confirm the message appears exactly once in each thread.
That closes the live gap. Each authenticated session now receives the conversation and message updates it needs without duplicate listeners.
Secret mission
Prove Reconnection Resilience
Interrupt the Express process while two authenticated tabs stay open. Restore it. Then prove one persisted message appears once per tab with one backend connection per tab.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your local chat environment running, pause it for another session, or remove its temporary resources. Everything runs locally, so there are no ongoing costs.
Resources you used:
- A local Node.js process running the Express API on port 5000.
- A local Vite process serving the React client at http://localhost:3000.
- A Docker Compose service running the PostgreSQL 16 container.
- The named pgdata volume containing your local chat data.
- The downloaded frontend dependencies inside client/node_modules.
Keep everything running
No action is needed. Choose this option if you are still testing live messages or reconnection behavior.
- Leave the Express API process running.
- Leave the Vite development server running.
- Leave the PostgreSQL container running through Docker Compose.
- Keep client/node_modules so the client remains ready to run.
Pause - I'll come back to this later
Stop the running processes to free their ports. Your database container plus its named volume remain available for your next session.
- Return to the terminal panel running the Express API from earlier.
- Press Ctrl+C to stop the API.
- Return to the terminal panel running the Vite development server from earlier.
- Press Ctrl+C to stop the client.
- Return to the root project terminal from earlier.
- Stop the PostgreSQL service without removing its container or volume by running this command:
docker compose stop
What Does This Command Do?
This command stops the Compose service while preserving the PostgreSQL container. Your pgdata volume keeps its local chat data for the next start.
- Refresh http://localhost:5000/api/health in your browser.
- Refresh http://localhost:3000 in your browser.
Both pages stop responding. This confirms that the local development environment is paused.
Delete - I don't want to use this again
Deleting the pgdata volume permanently removes your local users, conversations, messages, and refresh-token records. Your source code remains in the root project.
Your test files remain there too. You can rebuild the deleted dependencies with the existing client/package.json later.
- Return to the terminal panel running the Express API from earlier.
- Press Ctrl+C to stop the API.
- Return to the terminal panel running the Vite development server from earlier.
- Press Ctrl+C to stop the client.
- Return to the root project terminal from earlier.
- Remove the PostgreSQL container plus its named volume by running this command:
docker compose down --volumes
What Gets Removed?
This command stops and removes the Compose resources for the project. The --volumes flag also deletes the named pgdata volume.
The command output shows the PostgreSQL container plus its volume being removed. This confirms that the stored chat data has been deleted.
- Press the Windows key to open the search bar.
- Type File Explorer.
- Press Enter to open File Explorer.
- Navigate to the root project folder that contains client.
- Open the client folder.
- Select the node_modules folder.
- Press Delete to remove the installed frontend dependencies.
- Confirm that client/node_modules no longer appears in File Explorer.
Your local database data is gone. The installed frontend dependencies are gone too.
The root source code remains ready for reference or a future reinstall.
Nice Work!
Nice Work!
You did it! You built a tested React chat client for secure one-to-one messaging in real time.
You've learned how to:
- Restore cookie-backed sessions across reloads without exposing a JWT to React.
- Search public usernames to create or reopen one duplicate-safe direct conversation.
- Merge Socket.IO events by ID so every conversation or message appears once without a refresh. Prove the full flow with backend contract tests plus React interaction tests.
- Secret Mission: Prove reconnection resilience after an Express interruption. Each tab receives one persisted message exactly once after the restart.
Ready to quiz yourself?