149: Full-Stack Integration
Outcomes
By the end of this one-hour study session, you can:
- trace a task from React state through HTTP, Express, Mongoose, MongoDB, and back;
- keep network code in a small API service;
- configure Vite environment values and an explicit CORS allowlist correctly;
- implement create, read, update, and delete (CRUD) without stale UI data; and
- render distinct loading, error, empty, and success states.
Retrieval warm-up (5 minutes)
Answer before opening notes.
- Which HTTP methods normally represent create, read, update, and delete?
- Why must an Express route validate data even if the React form validates it?
- What does a Mongoose model do, and what does React state do?
Expected ideas: POST, GET, PATCH/PUT, DELETE; clients can be bypassed; a model reads/writes persistent documents while React state is a temporary UI snapshot.
Terms and end-to-end mental model
- Client/frontend: Browser side rendering UI and holding transient interaction state. — Source: MDN: HTTP Overview — clients
- API/backend: Server side exposing HTTP resources and enforcing rules/persistence. — Source: MDN: HTTP Overview — servers
- Resource: Target identified by a URI whose state transfers via representations. — Source: RFC 9110 §3.1
- Endpoint: Method plus path combination exposed by the API (PATCH /api/tasks/:id). — Source: RFC 9110 §9 Methods
- Serialization: Converting JavaScript data to JSON strings for transport/storage. — Source: MDN: JSON.stringify
- Persistence: Keeping data after processes restart; here provided by MongoDB. — Source: MongoDB Manual
- Origin: Scheme+host+port tuple defining the browser trust boundary. — Source: WHATWG URL: Origin
- CORS: Mechanism deciding which cross-origin responses scripts may read. — Source: MDN: CORS
- Preflight: OPTIONS probe browsers send before non-simple cross-origin requests. — Source: MDN: CORS — Preflighted requests
Follow one create request:
- You submit a React form.
- The handler calls
tasksApi.create({ title }). fetchsends JSON toPOST /api/tasks.- CORS middleware approves the exact frontend origin;
express.json()parses the body. - The route validates input. Mongoose validates and writes a document.
- Express responds
201,Location, and{ data: task }. - The API service checks
response.ok, parses JSON, and unwrapsdata. - React appends the server-created object using its public
idand re-renders.
Each layer has one responsibility. React must not connect directly to MongoDB: that would expose credentials and database access to every browser. Express must not trust React: HTTP callers can construct any request.
Core build (35 minutes)
Assume client and server folders. Use Node 24.19 LTS, Express 5.2.1, Mongoose 9.9.3, MongoDB driver 7.5.0, React 19.2.8, and Vite 8.2.2. Install server dependencies with npm install express mongoose cors dotenv and use ESM ("type": "module").
Contract carried forward from 104
This is an integration exercise, not a new task API. 104's public task fields remain id, title, completed, priority, createdAt, and updatedAt, and its GET /api/tasks/:id route remains available. The examples below add the cross-origin/client wiring; they do not intentionally remove priority or the single-task route. 106 later adds authentication and ownership as a documented breaking change for multi-user use: protected task requests then require a session, and a task belonging to another user is reported as 404.
1. Configure values, not secrets
client/.env.development:
VITE_API_URL=http://localhost:3000/api
server/.env or deployment environment:
MONGODB_URI=mongodb://127.0.0.1:27017/task_manager CLIENT_ORIGINS=http://localhost:5173 PORT=3000
Read client variables as import.meta.env.VITE_API_URL, never process.env. Vite exposes VITE_ values to bundled browser code as strings. Therefore VITE_API_URL is configuration, but database passwords, session secrets, private API keys, and peppers must never use the VITE_ prefix. Restart Vite after editing an env file. Commit an .env.example containing names and safe placeholders, not .env secrets.
2. Build a stable Express/Mongoose API
server/src/server.js:
import "dotenv/config";
import cors from "cors";
import express from "express";
import mongoose from "mongoose";
const taskSchema = new mongoose.Schema(
{
title: { type: String, required: true, trim: true, maxlength: 120 },
completed: { type: Boolean, default: false },
priority: { type: Number, enum: [1, 2, 3], default: 2 },
},
{
timestamps: true,
toJSON: {
transform: (_document, value) => {
value.id = value._id.toString();
delete value._id;
delete value.__v;
return value;
},
},
},
);
const Task = mongoose.model("Task", taskSchema);
const app = express();
const allowedOrigins = (process.env.CLIENT_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
app.use(cors({
origin(origin, callback) {
if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
const error = Object.assign(new Error("Request origin rejected"), {
status: 403,
code: "CORS_ORIGIN_DENIED",
});
callback(error);
},
credentials: true,
}));
app.use(express.json({ limit: "16kb" }));
const invalid = (message, details) => Object.assign(new Error(message), {
status: 400,
code: "VALIDATION_ERROR",
details,
});
const missing = () => Object.assign(new Error("Task not found"), {
status: 404,
code: "TASK_NOT_FOUND",
});
function taskInput(body, { partial = false } = {}) {
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw invalid("Invalid task", ["body must be a JSON object"]);
}
const allowed = new Set(["title", "completed", "priority"]);
const unknown = Object.keys(body).filter((key) => !allowed.has(key));
const details = unknown.map((key) => `${key} is not allowed`);
const input = {};
if (!partial || Object.hasOwn(body, "title")) {
if (typeof body.title !== "string" || !body.title.trim()) {
details.push("title must be a non-empty string");
} else if (body.title.trim().length > 120) {
details.push("title must be at most 120 characters");
} else input.title = body.title.trim();
}
if (Object.hasOwn(body, "completed")) {
if (typeof body.completed !== "boolean") details.push("completed must be boolean");
else input.completed = body.completed;
}
if (Object.hasOwn(body, "priority")) {
if (!Number.isInteger(body.priority) || ![1, 2, 3].includes(body.priority)) {
details.push("priority must be 1, 2, or 3");
} else input.priority = body.priority;
}
if (partial && Object.keys(body).length === 0) details.push("at least one field is required");
if (details.length) throw invalid("Invalid task", details);
return input;
}
app.get("/api/tasks", async (req, res) => {
const unknown = Object.keys(req.query).filter((key) => key !== "limit");
const limit = req.query.limit === undefined ? 50 : Number(req.query.limit);
if (unknown.length || !Number.isInteger(limit) || limit < 1 || limit > 100) {
throw invalid("Invalid query", ["only limit=1..100 is supported"]);
}
const tasks = await Task.find().sort({ createdAt: -1 }).limit(limit);
res.json({ data: tasks });
});
app.get("/api/tasks/:id", async (req, res) => {
const task = await Task.findById(req.params.id);
if (!task) throw missing();
res.json({ data: task });
});
app.post("/api/tasks", async (req, res) => {
const task = await Task.create(taskInput(req.body));
res.status(201).location(`/api/tasks/${task.id}`).json({ data: task });
});
app.patch("/api/tasks/:id", async (req, res) => {
const changes = taskInput(req.body, { partial: true });
const task = await Task.findByIdAndUpdate(req.params.id, { $set: changes }, {
returnDocument: "after",
runValidators: true,
});
if (!task) throw missing();
res.json({ data: task });
});
app.delete("/api/tasks/:id", async (req, res) => {
const task = await Task.findByIdAndDelete(req.params.id);
if (!task) throw missing();
res.status(204).end();
});
app.use((err, _req, res, _next) => {
if (err instanceof SyntaxError && err.status === 400 && "body" in err) {
return res.status(400).json({ error: { code: "MALFORMED_JSON", message: "Malformed JSON" } });
}
if (err.status === 413 || err.type === "entity.too.large") {
return res.status(413).json({ error: { code: "PAYLOAD_TOO_LARGE", message: "Request body too large" } });
}
if (err.name === "CastError" || err.name === "ValidationError") {
return res.status(400).json({ error: { code: "VALIDATION_ERROR", message: "Invalid task" } });
}
if (err.status) {
const error = { code: err.code, message: err.message };
if (err.details) error.details = err.details;
return res.status(err.status).json({ error });
}
console.error(err);
res.status(500).json({ error: { code: "INTERNAL_ERROR", message: "Internal server error" } });
});
await mongoose.connect(process.env.MONGODB_URI);
const port = Number(process.env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid PORT");
app.listen(port, () => console.log("API listening"));
Why these details matter: the CORS list is explicit rather than *; browsers forbid wildcard origin with credentials. Its callback produces the recognized CORS_ORIGIN_DENIED 403 for actual requests and preflight OPTIONS. Requests without an Origin are allowed for same-origin/server tools, but authorization will later protect data. PATCH rejects unknown fields, wrong primitive types, and an empty object. runValidators: true is required because Mongoose update validators are off by default. returnDocument: "after" lets the UI use server truth. A 204 response has no body.
3. Centralize Fetch behavior
client/src/api/tasks.js:
const API_URL = import.meta.env.VITE_API_URL;
async function request(path = "", options = {}) {
const response = await fetch(`${API_URL}/tasks${path}`, {
credentials: "include",
...options,
headers: { "Content-Type": "application/json", ...options.headers },
});
if (response.status === 204) return null;
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error?.message || `Request failed (${response.status})`);
}
return payload.data;
}
export const tasksApi = {
list: (signal) => request("", { signal }),
create: (input) => request("", { method: "POST", body: JSON.stringify(input) }),
update: (id, changes) => request(`/${id}`, {
method: "PATCH",
body: JSON.stringify(changes),
}),
remove: (id) => request(`/${id}`, { method: "DELETE" }),
};
Fetch rejects for network/abort failures, not for ordinary 404 or 500 responses, so always inspect response.ok. credentials: "include" prepares the service for 106's cross-origin development cookie. It requires the matching server CORS configuration. Do not add mode: "no-cors"; it produces an opaque, unreadable response rather than fixing CORS.
4. Integrate React with observable states
client/src/App.jsx (imports omitted only for brevity: import useEffect, useState, and tasksApi):
export default function App() {
const [tasks, setTasks] = useState([]);
const [title, setTitle] = useState("");
const [status, setStatus] = useState("loading");
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
tasksApi.list(controller.signal).then((tasks) => {
setTasks(tasks);
setStatus("ready");
}).catch((err) => {
if (err.name !== "AbortError") {
setError(err.message);
setStatus("error");
}
});
return () => controller.abort();
}, []);
async function addTask(event) {
event.preventDefault();
const cleanTitle = title.trim();
if (!cleanTitle) return;
setError("");
try {
const created = await tasksApi.create({ title: cleanTitle });
setTasks((current) => [created, ...current]);
setTitle("");
} catch (err) { setError(err.message); }
}
async function toggleTask(task) {
try {
const updated = await tasksApi.update(task.id, { completed: !task.completed });
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
} catch (err) { setError(err.message); }
}
async function deleteTask(id) {
try {
await tasksApi.remove(id);
setTasks((current) => current.filter((task) => task.id !== id));
} catch (err) { setError(err.message); }
}
return <main>
<h1>Task manager</h1>
<form onSubmit={addTask}>
<label htmlFor="new-task">New task</label>
<input id="new-task" value={title} maxLength="120"
onChange={(event) => setTitle(event.target.value)} />
<button disabled={!title.trim()}>Add</button>
</form>
{error && <p role="alert">{error}</p>}
{status === "loading" && <p aria-live="polite">Loading tasks...</p>}
{status === "error" && <button onClick={() => location.reload()}>Retry</button>}
{status === "ready" && tasks.length === 0 && <p>No tasks yet. Add the first one.</p>}
{status === "ready" && tasks.length > 0 && <ul>{tasks.map((task) =>
<li key={task.id}>
<label><input type="checkbox" checked={task.completed}
onChange={() => toggleTask(task)} /> {task.title}</label>
<button onClick={() => deleteTask(task.id)} aria-label={`Delete ${task.title}`}>Delete</button>
</li>)}</ul>}
</main>;
}
Functional state setters make simultaneous responses less likely to overwrite each other. React Strict Mode may run an Effect setup/cleanup cycle twice in development; aborting the first request makes the Effect resilient. For a larger app, a framework loader or maintained client cache can provide deduplication and caching.
Intermediate and optional advanced
Intermediate: disable the affected row while its mutation is pending, then re-enable it in finally. This prevents accidental duplicate actions and gives visible feedback. Add edit mode using the same PATCH endpoint. Keep the server response authoritative rather than guessing timestamps or normalized titles.
Optional advanced: add pagination, an indexed filter, and request cancellation when filters change. Consider TanStack Query or a React framework data API when caching, retries, invalidation, and server rendering become requirements. Do not add that complexity during the one-hour core.
Mistakes and debugging
- CORS message: inspect the Network tab for
OPTIONS; compare scheme, host, and port exactly; verify bothAccess-Control-Allow-OriginandAccess-Control-Allow-Credentials. Unexpected tokenparsing JSON: the API probably returned HTML or an empty204; inspect status/body and verify the URL.- UI reverts after refresh: the mutation changed only React state or the database write failed.
- Duplicate initial GET: expected under development Strict Mode; cleanup must make the Effect safe.
- Invalid ID becomes 500: map Mongoose
CastErrorto400as above. - Update returns old data or skips limits: use
returnDocument: "after"andrunValidators: true. - List keys use array index: deletion/reordering can attach DOM state to the wrong task; use stable public
id.
Accessibility, security, and performance checkpoint
Use real labels and buttons, keyboard-operable controls, role="alert" for failures, and an empty-state message. Do not replace the whole interface with a spinner after initial load. Validate size/type at the server, return generic 500 messages, limit JSON bodies, allowlist update fields, keep Mongo credentials server-side, and use TLS in production. CORS is not authorization. Sorting and limiting database results prevents unbounded payload growth; indexes should support production query patterns.
Tiered exercises
- Foundation: explain the eight create-request steps and identify where each validation happens.
- Core: add editing with an accessible input and the existing
PATCHroute. - Stretch: add a per-task pending state so repeated toggles/deletes are blocked.
For Foundation, use the numbered mental model above. Browser validation improves experience; Express/Mongoose validation protects the system.
For Core, render an edit form and replace the returned object:
async function renameTask(event, task, nextTitle) {
event.preventDefault();
const updated = await tasksApi.update(task.id, { title: nextTitle.trim() });
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
}
The form needs a visible <label htmlFor={edit-${task.id}}>Task title</label>, matching input ID, Save submit button, and Cancel button.
For Stretch:
const [pendingIds, setPendingIds] = useState(new Set());
async function withPending(id, operation) {
setPendingIds((ids) => new Set(ids).add(id));
try { await operation(); }
finally {
setPendingIds((ids) => {
const next = new Set(ids);
next.delete(id);
return next;
});
}
}
Call withPending(task.id, () => toggleTask(task)) and set both row buttons' disabled={pendingIds.has(task.id)}. Never mutate the existing Set; React needs a new state value.
Recap
The frontend owns interaction state; the backend owns rules and database access; MongoDB owns persistence. A small API service standardizes URL, credentials, JSON, status checking, and errors. Stable CRUD uses server-returned objects and IDs. A usable integration always shows loading, failure, empty, and populated states.
