MERN Cheat Sheet — Part 2
(verify) = not in my repos and I'm not 100% sure of the API. Versions: see Part 1 (backend: bullmq ^6.3.11, ioredis ^6.0.0, vitest ^4.1.11, supertest ^7.1.4, mongodb-memory-server ^10.2.3; frontend: react ^19.2.8, vite ^8.3.0, react-router-dom ^7.18.4, @tanstack/react-query ^5.104.1, react-hook-form ^7.89.0, @hookform/resolvers ^5.9.1, axios ^1.20.0, tailwindcss ^4.3.3, vitest ^5.0.3). Puppeteer and MySQL are in neither repo.
Table of contents
6. BullMQ + Redis
6.1 Folder structure
src/
config/redis.ts
queues/pdf.queue.ts # Queue instances (used by the API)
jobs/pdf.job.ts # job payload types + the processor function
workers/pdf.worker.ts # Worker wiring
workers/index.ts # separate process: starts all workers
Gotcha: my repo currently keeps queue + worker together in src/queues/pdf/; the API process should only import queues, never workers.
6.2 ioredis connection
import { Redis } from "ioredis";
import { env } from "./env.js";
export const connection = new Redis({
host: env.REDIS_HOST,
port: env.REDIS_PORT,
maxRetriesPerRequest: null, // required for BullMQ Workers
});
Gotcha: my repo has maxRetriesPerRequest: 0 — BullMQ warns/errors for Workers; null = retry forever.
6.3 Queue
import { Queue } from "bullmq";
import { connection } from "../config/redis.js";
export interface PdfJobData { reportId: string; userId: string }
export const pdfQueue = new Queue<PdfJobData>("export-pdf", {
connection,
defaultJobOptions: { removeOnComplete: true },
});
Gotcha: the queue name is the contract — Queue and Worker must use the exact same string.
6.4 Add job (attempts, backoff, deterministic jobId)
await pdfQueue.add(
"render",
{ reportId, userId },
{
jobId: `pdf-${reportId}`, // same id => no duplicate job
attempts: 3,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: { age: 3600 }, // keep 1h so status stays queryable
removeOnFail: { age: 24 * 3600 },
},
);
Gotcha: while a job with that jobId still exists, add is silently ignored — with removeOnComplete: true the id is freed immediately, so status lookups return nothing. Avoid : in custom ids (verify).
6.5 Worker with concurrency
import { Worker, type Job } from "bullmq";
export const pdfWorker = new Worker<PdfJobData>(
"export-pdf",
async (job: Job<PdfJobData>) => {
await job.updateProgress(10);
const path = await renderPdf(job.data.reportId);
await job.updateProgress(100);
return { path }; // becomes job.returnvalue
},
{ connection, concurrency: 2 },
);
Gotcha: throw to fail/retry; the thrown error's message ends up in failedReason.
6.6 Job events
pdfWorker.on("completed", (job) => logger.info({ id: job.id }, "done"));
pdfWorker.on("failed", (job, err) => logger.error({ id: job?.id, err }, "failed"));
pdfWorker.on("error", (err) => logger.error({ err }, "worker error")); // Redis/connection errors
Gotcha: without an error listener, a Redis connection error can crash the process; in failed, job can be undefined.
6.7 Job status endpoint
router.get("/exports/:reportId", authenticate, asyncHandler(async (req, res) => {
const job = await pdfQueue.getJob(`pdf-${req.params.reportId}`);
if (!job) throw new ApiError(404, "Job not found", "NOT_FOUND");
res.json({ success: true, data: {
state: await job.getState(), // waiting | active | completed | failed | delayed
progress: job.progress,
result: job.returnvalue,
error: job.failedReason,
} });
}));
Gotcha: check job.data.userId === req.auth.userId before returning — otherwise anyone can read anyone's job.
6.8 Separate worker process (src/workers/index.ts)
import { connectDatabase, disconnectDatabase } from "../config/db.js";
import { connection } from "../config/redis.js";
import { pdfWorker } from "./pdf.worker.js";
const workers = [pdfWorker];
await connectDatabase(); // top-level await OK with ESM
async function shutdown() {
await Promise.all(workers.map((w) => w.close())); // waits for active jobs to finish
await disconnectDatabase();
await connection.quit();
}
for (const sig of ["SIGINT", "SIGTERM"] as const) {
process.once(sig, () => { shutdown().then(() => process.exit(0)).catch(() => process.exit(1)); });
}
Gotcha: add "worker": "tsx watch src/workers/index.ts" / "start:worker": "node dist/workers/index.js" scripts; scale workers by running more processes.
6.9 Graceful close (API side)
await pdfQueue.close();
await connection.quit();
Gotcha: worker.close() waits for in-flight jobs; worker.close(true) force-closes and the job is retried as stalled.
6.10 docker-compose for Redis + Mongo
services:
mongo:
image: mongo:7
ports: ["27017:27017"]
volumes: [mongo_data:/data/db]
redis:
image: redis:7
ports: ["6479:6379"] # host 6479 -> container 6379 (matches REDIS_PORT default)
volumes: [redis_data:/data]
volumes:
mongo_data:
redis_data:
Gotcha: inside another container use the service name (redis, mongo), not localhost.
7. Puppeteer PDF
(Not in my repos — npm i puppeteer, latest stable assumed, (verify) on option details.)
7.1 Launch + render
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox"], // needed in most Docker setups
});
Gotcha: --no-sandbox is for containers; don't use it where you can run the sandbox.
7.2 setContent + pdf options
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({
format: "A4",
printBackground: true, // otherwise CSS backgrounds are dropped
margin: { top: "20mm", right: "15mm", bottom: "20mm", left: "15mm" },
displayHeaderFooter: false,
});
Gotcha: page.pdf returns a Uint8Array in recent versions (older: Buffer) — wrap with Buffer.from(pdf) before res.send/fs.writeFile (verify).
7.3 Close page and browser
const page = await browser.newPage();
try {
await page.setContent(html);
return await page.pdf({ format: "A4" });
} finally {
await page.close(); // always, even on error
}
Gotcha: leaked pages/browsers are leaked Chromium processes (hundreds of MB each).
7.4 Reuse one browser
import type { Browser } from "puppeteer";
let browserPromise: Promise<Browser> | undefined;
export const getBrowser = () => (browserPromise ??= puppeteer.launch({ args: ["--no-sandbox"] }));
export async function closeBrowser() {
if (browserPromise) await (await browserPromise).close();
browserPromise = undefined;
}
Gotcha: store the promise, not the browser, so concurrent first calls don't launch twice; call closeBrowser() on shutdown.
7.5 Inside a BullMQ worker
export const pdfWorker = new Worker<PdfJobData>("export-pdf", async (job) => {
const browser = await getBrowser();
const page = await browser.newPage();
try {
await page.setContent(await buildHtml(job.data.reportId), { waitUntil: "networkidle0" });
const pdf = await page.pdf({ format: "A4", printBackground: true });
await fs.writeFile(`exports/${job.data.reportId}.pdf`, pdf);
return { path: `exports/${job.data.reportId}.pdf` };
} finally {
await page.close();
}
}, { connection, concurrency: 2 });
Gotcha: concurrency = concurrent pages in one browser; add await closeBrowser() to the worker's shutdown (6.8).
8. SQL (MySQL)
No SQL in my repos; MySQL 8 assumed. Illustrative tables: employees(id, name, dept_id, salary, manager_id), departments(id, name), orders(id, customer_id, total, created_at).
8.1 INNER JOIN / LEFT JOIN
SELECT e.name, d.name AS dept
FROM employees e
INNER JOIN departments d ON d.id = e.dept_id; -- only matching rows
SELECT d.name, e.name
FROM departments d
LEFT JOIN employees e ON e.dept_id = d.id; -- all departments, NULL if no employees
Gotcha: a WHERE on the right table's column turns a LEFT JOIN into an INNER JOIN — put the condition in ON.
8.2 GROUP BY + HAVING
SELECT dept_id, COUNT(*) AS n, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept_id
HAVING COUNT(*) > 5
ORDER BY avg_salary DESC;
Gotcha: WHERE filters rows before grouping; HAVING filters groups after.
8.3 Subqueries
SELECT name FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees); -- scalar
SELECT name FROM employees
WHERE dept_id IN (SELECT id FROM departments WHERE name = 'Sales'); -- IN
SELECT d.name FROM departments d
WHERE EXISTS (SELECT 1 FROM employees e WHERE e.dept_id = d.id); -- correlated
Gotcha: prefer EXISTS over IN for large subqueries; NOT IN returns nothing if the subquery has a NULL.
8.4 Window functions: ROW_NUMBER, RANK
SELECT name, dept_id, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn,
RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk
FROM employees;
Gotcha: ROW_NUMBER never ties; RANK ties then skips (1,1,3); DENSE_RANK ties without skipping (1,1,2).
8.5 Top-N per group
SELECT * FROM (
SELECT e.*, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn
FROM employees e
) t
WHERE rn <= 3;
Gotcha: you can't filter on a window function in the same WHERE — wrap it in a subquery/CTE.
8.6 Second-highest value
SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 1;
-- NULL if there is no second value:
SELECT (SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 1) AS second_highest;
-- with ties handled:
SELECT salary FROM (SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS r FROM employees) t WHERE r = 2 LIMIT 1;
Gotcha: without DISTINCT, duplicates of the top salary count as "second".
8.7 Composite and covering indexes
CREATE INDEX idx_orders_cust_date ON orders (customer_id, created_at); -- composite
CREATE INDEX idx_orders_cover ON orders (customer_id, created_at, total); -- covering
Gotcha: leftmost-prefix rule — (customer_id, created_at) helps customer_id alone but not created_at alone; "covering" = every selected column is in the index, so no table lookup (Using index in EXPLAIN).
8.8 EXPLAIN
EXPLAIN SELECT total FROM orders WHERE customer_id = 42 ORDER BY created_at DESC;
-- EXPLAIN ANALYZE <query>; -- MySQL 8.0.18+: actually runs it, shows real timings
Gotcha: look at type (ALL = full scan; ref/range = good), key (index used), rows, and Extra (Using filesort / Using temporary = bad signs).
8.9 Transactions
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- or ROLLBACK;
Gotcha: needs InnoDB; DDL statements (CREATE/ALTER) implicitly commit. Use SELECT ... FOR UPDATE to lock rows you're about to change.
9. Testing
9.1 Vitest config + env setup
// vitest.config.ts (verify: backend config wasn't in my dump; I only saw tests/setup-env.ts)
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { environment: "node", setupFiles: ["./tests/setup-env.ts"], hookTimeout: 60_000 },
});
// tests/setup-env.ts
process.env.NODE_ENV = "test";
process.env.MONGO_URI = "mongodb://127.0.0.1:27017/auth_test";
process.env.LOG_LEVEL = "silent";
process.env.ACCESS_TOKEN_SECRET = "test-access-secret-with-at-least-32-characters";
process.env.REFRESH_TOKEN_SECRET = "test-refresh-secret-with-at-least-32-characters";
process.env.AUTH_COOKIE_SECURE = "false";
Gotcha: env must be set before env.ts is imported — that's why it's a setupFiles entry; first run downloads the MongoDB binary, hence the long hookTimeout.
9.2 mongodb-memory-server setup + teardown
import { afterAll, afterEach, beforeAll } from "vitest";
import mongoose from "mongoose";
import { MongoMemoryServer } from "mongodb-memory-server";
let mongo: MongoMemoryServer;
beforeAll(async () => {
mongo = await MongoMemoryServer.create();
await mongoose.connect(mongo.getUri());
await Promise.all([UserModel.init(), SessionModel.init(), ItemModel.init()]); // build unique indexes
});
afterEach(async () => {
await Promise.all([UserModel.deleteMany({}), SessionModel.deleteMany({}), ItemModel.deleteMany({})]);
});
afterAll(async () => {
await mongoose.disconnect();
await mongo.stop();
});
Gotcha: call Model.init() or unique-index tests (409) can flake because indexes build lazily.
9.3 supertest + authenticated request helper
import request from "supertest";
import { app } from "../src/app.js";
async function register(email: string) {
const res = await request(app).post("/api/auth/register")
.send({ email, password: "correct horse battery" });
expect(res.status).toBe(201);
return { userId: res.body.data.user._id as string, accessToken: res.body.data.accessToken as string };
}
const authed = (method: "get" | "post" | "patch" | "delete", path: string, token: string) =>
request(app)[method](path).set("Authorization", `Bearer ${token}`);
// const { accessToken } = await register("a@example.com");
// const res = await authed("post", "/api/items", accessToken).send({ title: "x" });
Gotcha: use request(app) (not a running server); for cookies, copy set-cookie[0].split(";")[0] into .set("Cookie", ...).
9.4 Cross-user access test
it("returns 404 for cross-owner read, update, delete", async () => {
const owner = await register("owner@example.com");
const stranger = await register("stranger@example.com");
const created = await authed("post", "/api/items", owner.accessToken).send({ title: "private" });
const id = created.body.data.item._id;
expect((await authed("get", `/api/items/${id}`, stranger.accessToken)).status).toBe(404);
expect((await authed("patch", `/api/items/${id}`, stranger.accessToken).send({ title: "stolen" })).status).toBe(404);
expect((await authed("delete", `/api/items/${id}`, stranger.accessToken)).status).toBe(404);
const unchanged = await ItemModel.findById(id);
expect(unchanged?.title).toBe("private");
});
Gotcha: always re-read the DB at the end — a 404 alone doesn't prove nothing changed.
9.5 Validation test
it("rejects bad fields and owner injection", async () => {
const { accessToken } = await register("v@example.com");
const bodies = [
{ title: "bad rating", rating: 6 },
{ title: " " },
{ title: "unknown", arbitrary: true },
{ title: "inject", owner: "507f1f77bcf86cd799439011" },
];
for (const body of bodies) {
expect((await authed("post", "/api/items", accessToken).send(body)).status).toBe(400);
}
expect(await ItemModel.countDocuments({})).toBe(0);
});
it("rejects NoSQL operator objects", async () => {
const res = await request(app).post("/api/auth/login")
.send({ email: { $gt: "" }, password: "correct horse battery" });
expect(res.status).toBe(400);
});
Gotcha: z.strictObject is what blocks unknown/owner keys; Zod type checks (z.string()) block { $gt: "" }.
10. React + Vite + TypeScript
10.1 Setup with Tailwind v4
npm create vite@latest app -- --template react-ts
npm i tailwindcss @tailwindcss/vite axios @tanstack/react-query react-router-dom react-hook-form @hookform/resolvers zod
// vite.config.ts
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: { proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true } } },
test: { environment: "jsdom", setupFiles: ["./src/test/setup.ts"], restoreMocks: true },
});
/* src/index.css */
@import "tailwindcss";
Gotcha: no tailwind.config.js/PostCSS needed in v4; the /api proxy makes the browser see same-origin (no CORS, cookies just work).
10.2 Router with protected and public-only routes
<Routes>
<Route element={<AppLayout />}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route element={<PublicOnlyRoute />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</Route>
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<DashboardPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
export function ProtectedRoute() {
const { isAuthenticated, isBooting } = useAuth();
const location = useLocation();
if (isBooting) return <Spinner />; // wait for session restore
if (!isAuthenticated) return <Navigate to="/login" replace state={{ from: location }} />;
return <Outlet />;
}
// PublicOnlyRoute: same, but isAuthenticated ? <Navigate to="/dashboard" replace /> : <Outlet />
Gotcha: without the isBooting gate you'd redirect to /login on every page refresh before the cookie session is restored. My repo imports from react-router-dom; v7 also exports the same from react-router (verify).
10.3 axios client + interceptors
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL || "/api",
withCredentials: true,
});
apiClient.interceptors.request.use((config) => {
const token = getAccessToken(); // in-memory variable
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
apiClient.interceptors.response.use((r) => r, async (error: AxiosError<ErrorResponse>) => {
const config = error.config as (InternalAxiosRequestConfig & { _authRetry?: boolean }) | undefined;
const isAuthRoute = /(?:^|\/)auth(?:\/|$)/.test(`${config?.baseURL ?? ""}${config?.url ?? ""}`);
if (error.response?.status !== 401 || !config || config._authRetry || isAuthRoute) {
return Promise.reject(error);
}
config._authRetry = true; // retry once only
try {
await refreshAccessToken();
return await apiClient.request(config);
} catch (e) {
setAccessToken(null);
window.dispatchEvent(new Event("auth:expired"));
return Promise.reject(e);
}
});
Gotcha: skip /auth/* routes in the retry logic or a failed login/refresh loops forever; keep the access token in memory, not localStorage.
10.4 Single-flight refresh + navigator.locks
let inFlight: Promise<string> | null = null;
export function refreshAccessToken(): Promise<string> {
if (inFlight) return inFlight; // same tab: share one request
const request = async () => {
const res = await axios.post<SuccessResponse<RefreshData>>(
`${apiBaseUrl}/auth/refresh`, undefined, { withCredentials: true });
setAccessToken(res.data.data.accessToken);
return res.data.data.accessToken;
};
const pending = navigator.locks
? navigator.locks.request("auth-refresh", request) // across tabs: one at a time
: request();
inFlight = pending;
const clear = () => { if (inFlight === pending) inFlight = null; };
void pending.then(clear, clear);
return pending;
}
Gotcha: use the bare axios (not apiClient) so the 401 interceptor can't recurse; this exists because the backend treats a reused refresh token as theft and kills the session.
10.5 TanStack Query: client + key factory
export const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 60_000, retry: (n, e) => !(axios.isAxiosError(e) && (e.response?.status ?? 500) < 500) && n < 2 },
mutations: { retry: false },
},
});
export const itemKeys = {
all: ["items"] as const,
lists: () => [...itemKeys.all, "list"] as const,
list: (q: ItemQuery) => [...itemKeys.lists(), q] as const,
details: () => [...itemKeys.all, "detail"] as const,
detail: (id: string) => [...itemKeys.details(), id] as const,
stats: () => [...itemKeys.all, "stats"] as const,
};
Gotcha: hierarchical keys let invalidateQueries({ queryKey: itemKeys.lists() }) hit every list at once; wrap the app in <QueryClientProvider client={queryClient}>.
10.6 useQuery + keepPreviousData
export function useItems(query: ItemQuery) {
return useQuery({
queryKey: itemKeys.list(query),
queryFn: async ({ signal }) => {
const res = await apiClient.get<SuccessResponse<ItemListData>>("/items", { params: query, signal });
return res.data.data;
},
placeholderData: keepPreviousData, // keep old page visible while the next loads
});
}
Gotcha: pass signal so cancelled queries abort the HTTP request; in v5 keepPreviousData is a placeholderData value, not an option.
10.7 useMutation + invalidate
export function useCreateItem() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: CreateItemInput) =>
(await apiClient.post<SuccessResponse<{ item: Item }>>("/items", input)).data.data.item,
onSuccess: () => Promise.all([
qc.invalidateQueries({ queryKey: itemKeys.lists() }),
qc.invalidateQueries({ queryKey: itemKeys.stats() }),
]),
});
}
// await createItem.mutateAsync(input) (throws) | createItem.mutate(input) (fire and forget)
Gotcha: return the promise from onSuccess so isPending stays true until the refetch finishes.
10.8 Optimistic update with rollback
export function useDeleteItem() {
const qc = useQueryClient();
return useMutation<string, Error, string, { prev: Array<[QueryKey, ItemListData | undefined]> }>({
mutationFn: async (id) => { await apiClient.delete(`/items/${id}`); return id; },
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: itemKeys.lists() }); // stop in-flight refetches
const prev = qc.getQueriesData<ItemListData>({ queryKey: itemKeys.lists() });
for (const [key, data] of prev) {
if (!data) continue;
qc.setQueryData<ItemListData>(key, {
...data, items: data.items.filter((i) => i._id !== id), total: Math.max(0, data.total - 1),
});
}
return { prev };
},
onError: (_e, _id, ctx) => { for (const [key, data] of ctx?.prev ?? []) qc.setQueryData(key, data); },
onSettled: () => qc.invalidateQueries({ queryKey: itemKeys.lists() }),
});
}
Gotcha: snapshot in onMutate, restore in onError, always re-sync in onSettled; cancelQueries first or a refetch overwrites the optimistic data.
10.9 react-hook-form + zod resolver
const schema = z.object({
email: z.email("Enter a valid email"),
password: z.string().min(12, "At least 12 characters"),
});
type Values = z.infer<typeof schema>;
const { register, handleSubmit, setError, formState: { errors, isSubmitting } } =
useForm<Values>({ resolver: zodResolver(schema) });
const onSubmit = async (values: Values) => {
try { await login(values); }
catch (e) { setError("email", { type: "server", message: "Email or password is incorrect" }); }
};
<form noValidate onSubmit={handleSubmit(onSubmit)}>
<input type="email" {...register("email")} aria-invalid={Boolean(errors.email)} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</form>
Gotcha: if a field is transformed (e.g. z.string().transform(Number)) use z.input<typeof schema> for useForm's type; map server details[].path back with setError. z.email() is the Zod 4 form (my repo still uses z.string().email()).
10.10 AuthContext with useAuth
export const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isBooting, setIsBooting] = useState(true);
useEffect(() => { // restore session from the refresh cookie
let active = true;
(async () => {
try {
await refreshAccessToken();
const res = await apiClient.get<SuccessResponse<{ user: User }>>("/users/me");
if (active) setUser(res.data.data.user);
} catch { setAccessToken(null); }
finally { if (active) setIsBooting(false); }
})();
return () => { active = false; };
}, []);
const value = useMemo(() => ({ user, isAuthenticated: user !== null, isBooting, login, logout }), [user, isBooting]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within an AuthProvider");
return ctx;
}
Gotcha: AuthProvider must sit inside <BrowserRouter> if it calls useNavigate. My useMemo omits login/logout from deps; that's only safe because they don't close over changing state — wrap them in useCallback to be lint-clean.
10.11 useEffect cleanup
useEffect(() => {
const onExpired = () => navigate("/login", { replace: true });
window.addEventListener("auth:expired", onExpired);
return () => window.removeEventListener("auth:expired", onExpired);
}, [navigate]);
Rule: anything you subscribe, start or fetch in an effect gets undone in its return (listeners, timers, active = false flags, AbortController).
10.12 useMemo
const visible = useMemo(() => items.filter((i) => i.status === status), [items, status]);
Rule: only for expensive computations or to keep an object/array identity stable for a child/context; not for cheap math.
10.13 useCallback
const handleDelete = useCallback((id: string) => deleteItem.mutate(id), [deleteItem.mutate]);
Rule: only useful when the function is passed to a memoized child (React.memo) or used as an effect dependency.
10.14 useRef
const inputRef = useRef<HTMLInputElement>(null);
const timer = useRef<number | undefined>(undefined);
inputRef.current?.focus();
timer.current = window.setTimeout(save, 500);
Rule: a ref holds a mutable value or DOM node that persists across renders and changing it does NOT re-render; use state if the UI must update.
11. Git, Docker and curl
11.1 Init + commit conventions
git init -b main
git add -A
git commit -m "feat(auth): add refresh token rotation"
# types: feat | fix | docs | refactor | test | chore | perf
git switch -c feat/sessions-page
git push -u origin feat/sessions-page
Gotcha: Conventional Commits = type(scope): imperative summary; one logical change per commit.
11.2 .gitignore for Node
node_modules/
dist/
build/
coverage/
.env
.env.*
!.env.example
*.log
.DS_Store
.vscode/
Gotcha: if .env was already committed, git rm --cached .env and rotate the secrets.
11.3 Docker Compose basics
docker compose up -d # start in background
docker compose ps
docker compose logs -f redis
docker compose exec mongo mongosh
docker compose down # stop + remove containers (keeps volumes)
docker compose down -v # ...and delete volumes (wipes data)
Gotcha: up reuses existing containers; add --build after changing a Dockerfile.
11.4 Dockerfile for the API (verify)
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
Gotcha: copy package*.json first so the npm ci layer is cached; add .dockerignore with node_modules.
11.5 curl: JSON
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"a@example.com","password":"correct horse battery"}'
Gotcha: add -i to see headers/Set-Cookie, -s | jq for pretty output.
11.6 curl: cookies (-c and -b)
curl -c cookies.txt -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" -d '{"email":"a@example.com","password":"correct horse battery"}'
curl -b cookies.txt -c cookies.txt -X POST http://localhost:3000/api/auth/refresh
Gotcha: -c writes the cookie jar, -b sends it; with secure: true cookies, curl over plain http://localhost may not send them back (use AUTH_COOKIE_SECURE=false in dev).
11.7 curl: bearer auth
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"a@example.com","password":"correct horse battery"}' | jq -r '.data.accessToken')
curl http://localhost:3000/api/items?status=open -H "Authorization: Bearer $TOKEN"
curl -X POST http://localhost:3000/api/items -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"title":"first item","rating":4}'
Gotcha: quote URLs that contain ? or & in zsh/bash scripts.
Top comments (0)