Goal: revise the ideas until I can explain the trade-offs, implement the patterns without copying, and diagnose failures in production. The running example is TaskFlow, a multi-tenant task application built with React 19, TypeScript, PostgreSQL, and a typed server layer.This is an original study guide inspired by the supplied chapter list, not a reproduction or summary of the unpublished book. APIs change, so verify version-sensitive details in the official documentation linked at the end.
0. The production mental model
A React application is not merely a tree of components. It is a distributed system with at least four places where state can live:
- The browser: input values, open dialogs, selected tabs, optimistic UI.
- The URL: route, search, filters, pagination, shareable view state.
- The server: authenticated session, business rules, cached responses.
- The database: durable source of truth.
useState. A task record belongs in the database and server cache, not in a global client store pretending to be authoritative. Whether a menu is open is local UI state; sending it to the server would be absurd.
My default flow is:
URL/request -> route boundary -> load validated data -> render
user intent -> validate -> authorize -> mutate transactionally
-> invalidate/revalidate -> render canonical result
TypeScript helps at compile time, but network input is still unknown. React controls rendering, not business correctness. The database provides durable consistency, but it does not know whether the current user is allowed to edit a row unless I encode that rule. Production readiness comes from connecting these layers deliberately.
Baseline TypeScript configuration
Use strict checking. Weak compiler settings move errors from development into users' browsers.
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
}
}
strict is the foundation. noUncheckedIndexedAccess reminds me that items[0] can be undefined. exactOptionalPropertyTypes distinguishes a missing property from one explicitly set to undefined. These feel inconvenient only while the code still hides assumptions.
Model domain states, not bags of optionals
type TaskId = string & { readonly __brand: "TaskId" };
type Task = Readonly<{
id: TaskId;
title: string;
status: "todo" | "doing" | "done";
assigneeId: string | null;
updatedAt: string;
}>;
type Loadable<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };
function TaskPanel({ result }: { result: Loadable<Task> }) {
switch (result.status) {
case "idle": return null;
case "loading": return <TaskSkeleton />;
case "success": return <TaskCard task={result.data} />;
case "error": return <p role="alert">{result.message}</p>;
}
}
A discriminated union prevents impossible combinations such as { loading: true, error: "failed", data: task }. The best type is often one that makes an invalid state unrepresentable.
Recall: Where is the source of truth? Which values cross a trust boundary? Which states are impossible but my current type allows?
1. React Server Components (RSC)
The distinction I must remember
- A Client Component can use state, effects, event handlers, and browser APIs. In an RSC framework it is marked by a top-level
'use client'boundary. - A Server Component renders in a server environment, can directly access server-only resources, and sends its rendered result rather than its component JavaScript to the browser.
- SSR renders initial HTML on a server, but client components can still be hydrated and shipped as JavaScript. SSR and RSC are related but not identical.
'use server'does not mark a Server Component. It marks a Server Function callable through the framework.
// app/projects/[projectId]/page.tsx — Server Component by framework convention
import { db } from "@/server/db";
import { TaskComposer } from "./TaskComposer";
export default async function ProjectPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const { projectId } = await params;
const project = await db.query.projects.findFirst({
where: (p, { eq }) => eq(p.id, projectId),
with: { tasks: true },
});
if (!project) return <h1>Project not found</h1>;
return (
<main>
<h1>{project.name}</h1>
<TaskComposer projectId={project.id} />
<ul>{project.tasks.map(task => <li key={task.id}>{task.title}</li>)}</ul>
</main>
);
}
// TaskComposer.tsx
"use client";
import { useState } from "react";
export function TaskComposer({ projectId }: { projectId: string }) {
const [title, setTitle] = useState("");
return (
<form>
<label>
New task
<input value={title} onChange={e => setTitle(e.currentTarget.value)} />
</label>
<input type="hidden" name="projectId" value={projectId} />
</form>
);
}
The boundary is transitive: importing a file from a 'use client' module pulls that dependency into the client graph. Keep the client island narrow. Pass serializable data and rendered children across the boundary; do not pass database clients, secrets, arbitrary class instances, or ordinary server closures.
Prevent waterfalls
This is slower because the second query waits for the first:
const project = await getProject(projectId);
const members = await getMembers(project.teamId);
When operations are independent, start them together:
const projectPromise = getProject(projectId);
const activityPromise = getRecentActivity(projectId);
const [project, activity] = await Promise.all([projectPromise, activityPromise]);
When the child query genuinely depends on the parent result, co-locate it in the child and use Suspense so the rest of the page can stream:
<ProjectHeader project={project} />
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed projectId={project.id} />
</Suspense>
RSC decision rule
Start server-side for pages and data-heavy display. Add client boundaries exactly where interaction requires them. Do not turn the entire page into a Client Component just because one button needs a click handler. Equally, do not force RSC into a purely client-side Vite application without a supporting framework.
Recall: Does this component need browser state or handlers? What JavaScript crosses the boundary? Can independent reads start concurrently?
2. Actions, server interactions, and caching
An Action represents a user intent such as “create task,” not a low-level setter. The production pipeline is: parse, validate, authenticate, authorize, mutate, invalidate, report a typed outcome.
// actions/create-task.ts
"use server";
import { z } from "zod";
const CreateTask = z.object({
projectId: z.string().uuid(),
title: z.string().trim().min(1).max(120),
});
type FormState =
| { ok: false; message: string; fields?: Record<string, string> }
| { ok: true; taskId: string };
export async function createTask(
_previous: FormState | null,
formData: FormData,
): Promise<FormState> {
const input = CreateTask.safeParse(Object.fromEntries(formData));
if (!input.success) {
return { ok: false, message: "Check the form values" };
}
const session = await requireSession();
const membership = await findMembership(session.user.id, input.data.projectId);
if (!membership || membership.role === "viewer") {
return { ok: false, message: "You cannot add tasks to this project" };
}
const task = await insertTask({ ...input.data, creatorId: session.user.id });
revalidatePath(`/projects/${input.data.projectId}`);
return { ok: true, taskId: task.id };
}
Never trust hidden inputs. A malicious caller can invoke the endpoint with another projectId. Authentication answers “who?” Authorization answers “may this user perform this action on this resource?” Both must happen on the server for every mutation.
React 19's useActionState connects an Action to state and pending UI:
"use client";
import { useActionState } from "react";
import { createTask } from "@/actions/create-task";
export function CreateTaskForm({ projectId }: { projectId: string }) {
const [state, action, pending] = useActionState(createTask, null);
return (
<form action={action}>
<input type="hidden" name="projectId" value={projectId} />
<label>
Title
<input name="title" required maxLength={120} aria-describedby="title-help" />
</label>
<small id="title-help">Use a short, actionable phrase.</small>
<button disabled={pending}>{pending ? "Adding…" : "Add task"}</button>
{state && !state.ok && <p role="alert">{state.message}</p>}
</form>
);
}
Expected failures—invalid title, duplicate name, insufficient stock—should usually be returned as data. Unexpected failures—database unavailable, violated invariant—should be thrown, logged with context, and handled by an error boundary.
Optimistic updates
Use optimism when success is likely, reversal is understandable, and the action is not dangerous.
const [optimisticTasks, addOptimistic] = useOptimistic(
tasks,
(current, draft: Task) => [...current, draft],
);
async function submit(formData: FormData) {
const title = String(formData.get("title"));
addOptimistic({
id: `temp-${crypto.randomUUID()}` as TaskId,
title,
status: "todo",
assigneeId: null,
updatedAt: new Date().toISOString(),
});
await createTaskOnServer(formData);
}
Do not optimistically claim “payment succeeded.” For high-risk operations show a pending state until the server confirms.
Think in cache scopes
There may be request memoization, a server data cache, route/output caching, CDN caching, and a browser cache. “Cached” is incomplete without naming the layer, key, lifetime, and invalidation event.
Cache public or safely partitioned reads. Never let one user's private response leak through a shared cache. Prefer tag/key invalidation related to the changed resource over flushing everything. Server Functions are chiefly for mutations; React's documentation specifically discourages treating them as a general cached-fetch mechanism.
Recall: Which input was validated? Where is authorization enforced? Which cache becomes stale after success? Is an optimistic rollback humane?
3. Advanced error handling and debugging
Classify errors before handling them:
- validation/domain error: expected, show near the user's action;
- authorization/not-found: controlled response, avoid leaking existence;
- network/transient error: retry only when safe;
- programmer/invariant error: throw, capture, fix;
- rendering error: catch at an Error Boundary;
- event-handler/async callback error: catch explicitly because boundaries do not catch every asynchronous origin.
type BoundaryState = { error: Error | null };
class AppErrorBoundary extends React.Component<
React.PropsWithChildren<{ fallback?: React.ReactNode }>,
BoundaryState
> {
state: BoundaryState = { error: null };
static getDerivedStateFromError(error: Error): BoundaryState {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
reportError(error, { componentStack: info.componentStack });
}
render() {
if (this.state.error) {
return this.props.fallback ?? <p role="alert">This section failed.</p>;
}
return this.props.children;
}
}
Place boundaries at recovery boundaries: the whole app, each route, and independently useful widgets. One giant boundary turns a chart bug into a blank application; hundreds of tiny boundaries create noise.
Use unknown in catches and normalize deliberately:
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return "Unknown failure";
}
async function save() {
try {
await api.saveDraft();
} catch (error: unknown) {
logger.warn("draft_save_failed", { message: errorMessage(error) });
setNotice("Draft could not be saved. Your text remains on this page.");
}
}
Debug systematically
- State the observed and expected behavior.
- Reproduce with the smallest stable input.
- Identify the first layer where reality diverges: event, state, request, response, database, render.
- Inspect evidence: React DevTools, Network panel, server log, trace, SQL.
- Form one falsifiable hypothesis.
- Change one variable or add one observation.
- Add a regression test after fixing.
logger.info("task_created", {
requestId,
userId: session.user.id,
projectId,
taskId: task.id,
durationMs: performance.now() - started,
});
Never log passwords, session tokens, full cookies, secret keys, or unnecessary personal data. A useful production error includes a stable code, request/trace ID, operation, sanitized context, and original stack on the trusted server.
React Strict Mode intentionally exposes impure rendering and missing cleanup in development. If an effect breaks when mounted, cleaned up, and mounted again, fix the effect's symmetry instead of disabling the detector.
Recall: Is this expected or exceptional? Can the user recover locally? What evidence will connect the browser failure to its server request?
4. Advanced state management
Use the smallest sufficient state tool:
| State | Default home |
|---|---|
| Input, toggle, dialog | component useState |
| Complex local transition logic | useReducer |
| Shareable filter/page | URL search params |
| Theme/current locale | Context |
| Remote durable records | router/framework loader or query cache |
| Cross-feature client workflow | external store, only when justified |
// Avoid: filteredTasks can become stale.
const [filteredTasks, setFilteredTasks] = useState(tasks);
// Prefer: derive during render.
const visibleTasks = tasks.filter(task =>
filter === "all" ? true : task.status === filter,
);
Memoize only if profiling or identity stability justifies it:
const visibleTasks = useMemo(
() => expensiveRank(tasks, query),
[tasks, query],
);
For coordinated transitions, a reducer names legal events:
type EditorState =
| { kind: "closed" }
| { kind: "editing"; draft: string; dirty: boolean }
| { kind: "saving"; draft: string }
| { kind: "failed"; draft: string; message: string };
type EditorEvent =
| { type: "OPEN"; title: string }
| { type: "CHANGE"; value: string }
| { type: "SAVE" }
| { type: "SAVED" }
| { type: "FAILED"; message: string }
| { type: "CLOSE" };
function editorReducer(state: EditorState, event: EditorEvent): EditorState {
switch (event.type) {
case "OPEN": return { kind: "editing", draft: event.title, dirty: false };
case "CHANGE":
return state.kind === "editing"
? { ...state, draft: event.value, dirty: true }
: state;
case "SAVE":
return state.kind === "editing" ? { kind: "saving", draft: state.draft } : state;
case "SAVED": return { kind: "closed" };
case "FAILED":
return "draft" in state
? { kind: "failed", draft: state.draft, message: event.message }
: state;
case "CLOSE": return { kind: "closed" };
}
}
Context broadcasts when its provided value identity changes. Split context by update frequency and responsibility; do not create one AppContext containing user, theme, tasks, modals, and notifications.
const ThemeContext = createContext<"light" | "dark">("light");
function ThemeProvider({ children }: React.PropsWithChildren) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
The shown context declaration and provided value have different types—a useful reminder to define the full contract:
type ThemeValue = {
theme: "light" | "dark";
setTheme: React.Dispatch<React.SetStateAction<"light" | "dark">>;
};
const ThemeContext = createContext<ThemeValue | null>(null);
That deliberate correction is how I should review code: types must describe the value actually provided.
Recall: Can I delete this state and derive it? Should the URL own it? Am I confusing a server cache with a client store?
5. Anti-patterns and best practices
Effect as an escape hatch, not a workflow engine
Effects synchronize React with something external: a subscription, browser API, timer, map widget, or non-React system. They are usually unnecessary for derivation or user-event logic.
// Bad: extra render and stale-state risk.
useEffect(() => setFullName(`${first} ${last}`), [first, last]);
// Good:
const fullName = `${first} ${last}`;
// Bad: reacts indirectly to an event.
useEffect(() => {
if (submitted) sendOrder(cart);
}, [submitted, cart]);
// Good: event handler owns the intent.
async function handleSubmit() {
await sendOrder(cart);
}
When an effect fetches, prevent stale responses or use the router/query layer:
useEffect(() => {
const controller = new AbortController();
void fetch(`/api/tasks?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<Task[]>;
})
.then(setTasks)
.catch(error => {
if (!(error instanceof DOMException && error.name === "AbortError")) throw error;
});
return () => controller.abort();
}, [query]);
Other recurring anti-patterns:
- using array indexes as keys for reorderable lists;
- copying props into state and then trying to synchronize them;
- mutating arrays/objects and reusing the same reference;
- giant components that mix fetching, policy, transformation, and presentation;
- premature
useMemo,useCallback, andmemoeverywhere; - boolean-prop explosions such as
primary,danger,compact,quietthat allow contradictory variants; - catching errors and silently returning
null; - making every component generic when a concrete domain type is clearer;
- assuming TypeScript validates JSON at runtime.
{tasks.map(task => <TaskRow key={task.id} task={task} />)}
Use immutable updates:
setTasks(current =>
current.map(task => task.id === id ? { ...task, status: "done" } : task),
);
Prefer composition over prop flags:
<Dialog>
<Dialog.Title>Delete project?</Dialog.Title>
<Dialog.Body>This removes all tasks.</Dialog.Body>
<Dialog.Actions>
<Button variant="secondary">Cancel</Button>
<Button variant="danger">Delete</Button>
</Dialog.Actions>
</Dialog>
Component design test
A good component has a clear responsibility, an accessible public contract, predictable ownership of state, and a reason to change. “Reusable” does not mean configurable for imagined futures. Extract when multiple consumers share a stable idea or when a boundary improves comprehension/testing.
Recall: What external system is this effect synchronizing? Is this abstraction paid for by current use? Does identity carry semantic meaning here?
6. Styling and scalable design systems
A design system is shared decisions: tokens, accessible primitives, composition patterns, documentation, and governance. A component folder alone is not a system.
:root {
--color-bg: oklch(98% 0.01 255);
--color-surface: white;
--color-text: oklch(24% 0.03 255);
--color-accent: oklch(58% 0.2 258);
--color-danger: oklch(55% 0.22 25);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--radius-sm: 0.375rem;
--radius-md: 0.625rem;
--shadow-raised: 0 0.25rem 1rem rgb(0 0 0 / 0.12);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: oklch(18% 0.02 255);
--color-surface: oklch(23% 0.02 255);
--color-text: oklch(94% 0.01 255);
}
}
Tokens encode meaning. Components should use --color-danger, not memorize a red hex value. Later the visual language can change centrally.
Type variants so contradictory props are impossible:
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "danger";
size?: "sm" | "md" | "lg";
loading?: boolean;
};
export function Button({
variant = "primary",
size = "md",
loading = false,
disabled,
children,
...props
}: ButtonProps) {
return (
<button
{...props}
className={`button button--${variant} button--${size}`}
disabled={disabled || loading}
aria-busy={loading || undefined}
>
{loading ? "Working…" : children}
</button>
);
}
Preserve native semantics. A navigation link is an anchor, not a button with onClick; a submit control is a button. Forward valid native props and refs when building primitives. Support keyboard focus with visible :focus-visible, sufficient contrast, zoom, reduced motion, and touch target sizes. Test with keyboard and a screen reader, not only an automated checker.
.button:focus-visible {
outline: 3px solid color-mix(in oklab, var(--color-accent), white 25%);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Choose CSS Modules, utility CSS, CSS-in-JS, or plain CSS based on team constraints, SSR support, runtime cost, theming, and authoring experience. Consistency matters more than fashion. Keep app-specific layouts outside universal primitives.
Recall: Is this a semantic token or a raw value? Can a keyboard user operate it? Does the primitive preserve native HTML behavior?
7. React Hooks deeply understood
Hooks rely on call order. Call them unconditionally at the top level of a component or custom Hook. Dependencies are not a preference list; they describe every reactive value read by the effect or memo.
Functional updates avoid stale closures
setCount(current => current + 1);
This is safer than setCount(count + 1) when updates can queue. A render is a snapshot: event handlers see the props and state from the render that created them.
Ref versus state
State affects rendering and triggers a render. A ref persists across renders without triggering one.
function Stopwatch() {
const startedAt = useRef<number | null>(null);
const [elapsed, setElapsed] = useState(0);
function start() {
startedAt.current = performance.now();
}
function stop() {
if (startedAt.current !== null) {
setElapsed(performance.now() - startedAt.current);
startedAt.current = null;
}
}
return <button onMouseDown={start} onMouseUp={stop}>{elapsed.toFixed(0)} ms</button>;
}
Custom Hooks share behavior, not state
Each invocation owns independent state unless it connects to the same external store.
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
const update = () => setMatches(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, [query]);
return matches;
}
For an external store, useSyncExternalStore gives React a consistent subscription contract:
function useOnlineStatus() {
return useSyncExternalStore(
callback => {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
},
() => navigator.onLine,
() => true,
);
}
useTransition marks non-urgent rendering work; it does not make CPU work disappear. useDeferredValue lets a slowly rendered view lag behind urgent input. useId creates stable IDs for accessibility relationships, not list keys. useImperativeHandle should expose a small intentional imperative API, not the component's internals.
React 19's use can read a Promise or context during rendering in supported environments and integrates with Suspense. Do not create a fresh Promise on every client render; use framework-managed or cached promises.
Recall: What snapshot does this closure see? Does this value affect rendering? Is my effect cleanup the exact inverse of setup?
8. React Router 7
React Router 7 supports three modes:
- Declarative: basic matching, links, navigation, and location.
- Data: route objects plus loaders, actions, pending states, and fetchers.
- Framework: adds a Vite plugin, route modules, type generation, code splitting, and SPA/SSR/static strategies.
const router = createBrowserRouter([
{
path: "/",
Component: RootLayout,
errorElement: <RouteError />,
children: [
{ index: true, Component: Dashboard },
{
path: "projects/:projectId",
loader: projectLoader,
action: projectAction,
Component: ProjectRoute,
},
],
},
]);
export async function projectLoader({ params, request }: LoaderFunctionArgs) {
if (!params.projectId) throw new Response("Missing project", { status: 400 });
const url = new URL(request.url);
const status = url.searchParams.get("status") ?? "all";
const project = await api.projects.get(params.projectId, { status });
if (!project) throw new Response("Not found", { status: 404 });
return { project, status };
}
export function ProjectRoute() {
const { project, status } = useLoaderData() as Awaited<
ReturnType<typeof projectLoader>
>;
return <ProjectScreen project={project} status={status} />;
}
In Framework mode route-specific generated types are preferable because they keep params and loader data synchronized with route configuration.
Use the URL for user-visible navigation state:
const [params, setParams] = useSearchParams();
const status = params.get("status") ?? "all";
function changeStatus(next: string) {
setParams(current => {
const copy = new URLSearchParams(current);
if (next === "all") copy.delete("status");
else copy.set("status", next);
copy.delete("page");
return copy;
});
}
useFetcher performs loader/action interactions without navigating—ideal for inline mutations, autocomplete, and independent widgets. Route actions also centralize mutation and automatic revalidation. Use / for navigation so modifier-click, accessibility, and history semantics work.
Route-level error boundaries should distinguish status responses from unexpected errors. Preserve meaningful 404, 401/403, and 500 behavior on the server as well as in the UI.
Recall: Which router mode am I using? Is this state navigational? Should this mutation navigate or use a fetcher?
9. Advanced forms
Forms are data-entry systems with parsing, validation, accessibility, pending behavior, errors, and resubmission rules. Start with native HTML; add a library when dynamic arrays, nested values, or performance make it worthwhile.
const TaskInput = z.object({
title: z.string().trim().min(3, "Use at least 3 characters").max(120),
priority: z.enum(["low", "normal", "high"]),
dueDate: z.string().date().optional().or(z.literal("")),
});
type TaskInput = z.infer<typeof TaskInput>;
Client validation improves speed; server validation provides security. Share a schema when environments and transformations are compatible, but never skip validation server-side.
Controlled versus uncontrolled
A controlled input gets value and onChange; React owns every update. An uncontrolled input keeps its current value in the DOM and is read with FormData or a ref. Controlled is useful for live formatting and dependent fields. Uncontrolled often means less code and fewer renders.
function TaskForm() {
const [title, setTitle] = useState("");
const remaining = 120 - title.length;
return (
<form action={createTask}>
<label htmlFor="task-title">Task title</label>
<input
id="task-title"
name="title"
value={title}
onChange={e => setTitle(e.currentTarget.value)}
aria-describedby="title-count"
maxLength={120}
/>
<span id="title-count">{remaining} characters remaining</span>
<SubmitButton />
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>;
}
useFormStatus must be rendered inside the relevant form via a child component. Disable only what must be disabled; sometimes users should still be able to cancel or edit another section. Avoid replacing button text if doing so causes layout shift.
Error design
Provide a summary for large forms and connect field errors using aria-invalid and aria-describedby. Move focus to the error summary after a failed submission when appropriate. Preserve safe entered values. Never echo passwords or sensitive data back into HTML.
<input
id="email"
name="email"
type="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && <p id="email-error">{errors.email}</p>}
Debounced async validation needs cancellation and should not be the only enforcement. Prevent duplicate financial submissions with server-side idempotency keys, not just a disabled button.
Recall: Who owns this input? Is the same rule enforced on the server? Can the user locate and understand every error?
10. Scalable architecture and project setup
Organize primarily by feature/domain, with a small shared layer:
src/
app/ # composition, providers, routes
features/
tasks/
api/
components/
model/
routes/
tests/
projects/
identity/
shared/
ui/ # stable design-system primitives
lib/ # framework-neutral utilities
config/
server/
db/
auth/
observability/
Feature boundaries reduce “horizontal tourism” across global components, hooks, services, and types directories. Colocation makes deletion and ownership easier. Promote code to shared only when reuse is real and its API is stable.
Use dependency direction:
app -> features -> shared
server adapters -> domain contracts
domain must not import UI or database implementation
Define ports when replaceability or testing value is real:
export interface TaskRepository {
findByProject(projectId: string): Promise<readonly Task[]>;
create(input: CreateTaskInput): Promise<Task>;
}
export function makeCreateTask(repo: TaskRepository, clock: () => Date) {
return async (input: CreateTaskInput) => {
if (!input.title.trim()) throw new DomainError("EMPTY_TITLE");
return repo.create({ ...input, createdAt: clock().toISOString() });
};
}
Do not wrap every library “just in case.” Boundaries are valuable around volatile infrastructure or important domain behavior, but pass-through layers add ceremony.
Configuration
Validate environment variables at startup:
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
AUTH_SECRET: z.string().min(32),
PUBLIC_APP_URL: z.string().url(),
});
export const env = Env.parse(process.env);
Only explicitly public variables may enter the browser bundle. A “public” prefix is a disclosure mechanism, not access control.
Pin the runtime version, commit the lockfile, enable linting/type checking/tests, and keep one repeatable command for CI. Add architectural rules only when they prevent an observed failure mode. Record major decisions as short Architecture Decision Records: context, decision, consequences.
Recall: Can I delete a feature without hunting across the repository? Do dependencies point inward toward stable rules? Is startup configuration validated once?
11. Authentication and authorization with Auth.js/NextAuth
Authentication establishes identity. Authorization decides access. A session is not permission.
Current Auth.js-style Next.js setup:
// auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
export const { auth, handlers, signIn, signOut } = NextAuth({
providers: [GitHub],
callbacks: {
authorized({ auth, request }) {
const protectedPath = request.nextUrl.pathname.startsWith("/app");
return !protectedPath || Boolean(auth?.user);
},
},
});
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
Protecting a page is not enough. Protect each data operation:
export async function renameProject(projectId: string, name: string) {
const session = await auth();
if (!session?.user?.id) throw new UnauthorizedError();
const membership = await db.query.memberships.findFirst({
where: (m, { and, eq }) => and(
eq(m.projectId, projectId),
eq(m.userId, session.user.id),
),
});
if (!membership || !["owner", "editor"].includes(membership.role)) {
throw new ForbiddenError();
}
return db.update(projects)
.set({ name })
.where(eq(projects.id, projectId))
.returning();
}
For multi-tenant systems, query by both resource and tenant membership. Fetching by resource ID and checking later can create leakage through timing, logs, or forgotten call sites. Centralize policy helpers and consider database row-level security where appropriate, while still testing application policy.
Security checklist:
- use HTTPS and secure cookie settings in production;
- rotate and protect secrets;
- validate OAuth redirect/callback configuration;
- implement CSRF defenses for cookie-authenticated mutations;
- rate-limit sensitive endpoints;
- do not expose whether a private resource exists;
- require recent authentication for destructive account operations;
- expire/revoke sessions appropriately;
- audit high-value authorization decisions;
- never rely on a hidden button as access control.
Recall: Where is authorization enforced for this exact object? Can changing a URL ID cross tenants? What happens when a role changes during a session?
12. Express, Node.js, PostgreSQL, and Drizzle ORM
Build APIs around resources and domain operations. Validate at the edge, keep controllers thin, and make database constraints the last line of defense.
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json({ limit: "100kb" }));
const CreateTaskBody = z.object({
projectId: z.string().uuid(),
title: z.string().trim().min(1).max(120),
});
app.post("/api/tasks", requireUser, async (req, res, next) => {
try {
const body = CreateTaskBody.parse(req.body);
await requireProjectRole(req.user.id, body.projectId, ["owner", "editor"]);
const [task] = await db.insert(tasks).values({
projectId: body.projectId,
title: body.title,
creatorId: req.user.id,
}).returning();
res.status(201).location(`/api/tasks/${task.id}`).json({ data: task });
} catch (error) {
next(error);
}
});
Define schema and constraints:
export const taskStatus = pgEnum("task_status", ["todo", "doing", "done"]);
export const tasks = pgTable("tasks", {
id: uuid("id").defaultRandom().primaryKey(),
projectId: uuid("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
title: varchar("title", { length: 120 }).notNull(),
status: taskStatus("status").notNull().default("todo"),
creatorId: uuid("creator_id").notNull().references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, table => [
index("tasks_project_status_idx").on(table.projectId, table.status),
]);
An ORM gives query composition and types; it does not remove the need to understand SQL, query plans, indexes, isolation, locks, and transactions.
await db.transaction(async tx => {
const [task] = await tx.insert(tasks).values(input).returning();
await tx.insert(auditEvents).values({
kind: "task.created",
actorId: userId,
subjectId: task.id,
});
});
Use transactions when several writes form one invariant. Keep them short; do not call a slow external service while holding database locks. For reliable event publication, write an outbox record in the same transaction, then publish asynchronously.
Paginate large collections. Offset pagination is simple; cursor/keyset pagination is more stable under concurrent inserts:
const page = await db.select().from(tasks)
.where(and(eq(tasks.projectId, projectId), lt(tasks.createdAt, cursor)))
.orderBy(desc(tasks.createdAt), desc(tasks.id))
.limit(51);
Return consistent errors, for example { error: { code, message, requestId, fields? } }. Do not expose SQL strings or stacks to clients. Implement graceful shutdown: stop accepting traffic, finish bounded in-flight work, and close the pool.
Recall: Which invariant is guaranteed by a database constraint? Does this need a transaction? What query will this endpoint execute at 100 times today's data?
13. Internationalization and localization
Internationalization prepares the application; localization adapts it to a language/region. Translation is only one piece. Dates, numbers, currencies, plural rules, sorting, names, layout direction, and text expansion also matter.
Use message keys and semantic phrases, not concatenated fragments:
const messages = {
en: {
taskCount: ({ count }: { count: number }) =>
new Intl.PluralRules("en").select(count) === "one"
? `${count} task`
: `${count} tasks`,
},
};
In a real product use an ICU-message-capable library so translators can control plural/select grammar:
{count, plural,
=0 {No tasks}
one {# task}
other {# tasks}
}
Use platform formatters:
export function formatMoney(amountMinor: number, currency: string, locale: string) {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amountMinor / 100);
}
export function formatDate(iso: string, locale: string, timeZone: string) {
return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
timeZone,
}).format(new Date(iso));
}
Store instants in a consistent representation (commonly UTC), but format in the user's intended time zone. A date-only birthday is not an instant; model it as a date, or timezone conversion may shift it.
Route locale choices (/en/projects) are crawlable and shareable; cookie/profile locale can be convenient. Define precedence such as explicit URL, saved preference, accepted language, default. Set and dir="rtl" where needed. Prefer CSS logical properties such as margin-inline-start rather than left/right.
Test pseudo-localization, long strings, missing keys, right-to-left layout, non-Latin input, plural cases including zero, and multiple zones around daylight-saving transitions. Avoid flags as language icons: a language is not a country.
Recall: Is this an instant or a calendar date? Can the translator reorder the full sentence? Does layout work when inline direction reverses?
14. Automated testing
Test behavior at the cheapest level that provides confidence:
- unit tests for pure domain rules and transformations;
- component/integration tests for user-visible behavior across collaborators;
- API/database tests for serialization, constraints, and authorization;
- a small number of end-to-end tests for critical journeys.
test("creates a task and clears the form", async () => {
const user = userEvent.setup();
server.use(
http.post("/api/tasks", async ({ request }) => {
const body = await request.json() as { title: string };
return HttpResponse.json({
data: { id: "t1", title: body.title, status: "todo" },
}, { status: 201 });
}),
);
render(<CreateTask />);
await user.type(screen.getByRole("textbox", { name: /task title/i }), "Write tests");
await user.click(screen.getByRole("button", { name: /add task/i }));
expect(await screen.findByText("Write tests")).toBeVisible();
expect(screen.getByRole("textbox", { name: /task title/i })).toHaveValue("");
});
Mock Service Worker-style network interception keeps tests near the HTTP contract instead of mocking internal hooks. Test errors and latency, not only success.
it("rejects viewers", async () => {
const result = renameProject({ role: "viewer" }, project, "Secret");
await expect(result).rejects.toMatchObject({ code: "FORBIDDEN" });
});
For database tests, use an isolated real PostgreSQL instance or schema when SQL behavior matters. Apply migrations, seed minimal data, and clean deterministically. Do not pretend an in-memory substitute proves PostgreSQL constraints.
End-to-end example:
test("owner invites a member", async ({ page }) => {
await loginAs(page, "owner@example.test");
await page.goto("/app/projects/p1/settings/members");
await page.getByRole("button", { name: "Invite member" }).click();
await page.getByLabel("Email").fill("member@example.test");
await page.getByRole("button", { name: "Send invitation" }).click();
await expect(page.getByText("Invitation sent")).toBeVisible();
});
Control time, randomness, and network at boundaries. Avoid arbitrary sleeps; wait for observable UI. A flaky test is a product signal or a bad synchronization strategy, not background noise.
Coverage identifies unexecuted code but does not measure assertion quality. Mutation testing can reveal tests that execute a branch without actually detecting wrong behavior.
Recall: What user-observable contract is protected? Would this test survive a safe refactor? Did I test permissions, failure, and loading as well as success?
15. CI/CD
Continuous integration makes every change prove it can merge. Continuous delivery keeps a releasable artifact ready; continuous deployment automatically releases passing changes.
A sensible pipeline:
name: verify
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run test:coverage
- run: npm run build
Pin action major versions at minimum, and pin immutable commit SHAs for stricter supply-chain control. Give workflow tokens minimal permissions. Do not execute untrusted pull-request code with production secrets. Use protected environments for deploy credentials and approvals.
Build once, promote the same immutable artifact through environments. Rebuilding for production can produce a different dependency graph or output. Attach a source revision and build ID. Generate an SBOM or vulnerability report according to risk, but triage findings rather than treating scanner output as truth.
Database migrations
Use backward-compatible expand/contract changes:
- expand: add a nullable column/table/index;
- deploy code that writes old and new shapes if required;
- backfill safely in batches;
- switch reads;
- remove the old shape in a later release.
Release strategy
Rolling deployments are common. Blue/green offers fast traffic switching at higher resource cost. Canaries expose a small traffic slice and require good observability. Feature flags separate code deployment from feature release, but flags need owners and expiry dates.
Rollback must include data compatibility. If a migration destroys old data, rolling back code is insufficient. Prefer roll-forward when state has evolved incompatibly.
Recall: Is the artifact identical across environments? Can old and new versions run concurrently? What exact signal stops or rolls back a release?
16. Performance optimization
Performance is a user outcome, not a Lighthouse trophy. Measure real-user latency by route, device, geography, and percentile. Optimize the bottleneck supported by evidence.
Key web experience signals include Largest Contentful Paint, Interaction to Next Paint, and Cumulative Layout Shift. Also watch server response time, JavaScript execution, memory, API latency, cache hit rate, database time, and error rate.
Render performance
First fix state placement and component boundaries. A keystroke should not rerender an entire dashboard because input state lives too high.
const TaskRow = memo(function TaskRow({ task, onToggle }: {
task: Task;
onToggle: (id: TaskId) => void;
}) {
return (
<label>
<input
type="checkbox"
checked={task.status === "done"}
onChange={() => onToggle(task.id)}
/>
{task.title}
</label>
);
});
memo only helps when props remain referentially stable and render cost matters. Measure with React Profiler. A new inline object or callback can defeat shallow comparison, but wrapping everything in memoization also costs memory and complexity.
Virtualize genuinely long lists. Preserve accessibility, focus, and scroll behavior. Paginating on the server is often better than downloading ten thousand rows to virtualize.
Network and bundles
- split by route and heavy optional features;
- analyze bundles and remove duplicate/oversized dependencies;
- ship modern formats and compression;
- preload only truly critical assets;
- size images correctly and reserve dimensions to prevent layout shift;
- minimize third-party scripts;
- stream useful server-rendered content with Suspense boundaries;
- avoid sequential request waterfalls.
const AdminAnalytics = lazy(() => import("./AdminAnalytics"));
{showAnalytics && (
<Suspense fallback={<ChartSkeleton />}>
<AdminAnalytics />
</Suspense>
)}
Server and database
Use EXPLAIN (ANALYZE, BUFFERS) in a safe environment for slow queries. Add indexes for observed access paths, remembering indexes increase write cost. Eliminate N+1 queries, bound payloads, pool connections appropriately, and cache results with explicit invalidation.
Set performance budgets in CI, but use them as guardrails rather than substitutes for real-user monitoring. A lab regression is a warning; a real-user percentile regression is impact.
Recall: Which metric and percentile improved? Did I reduce work or merely move it? Could caching return private or stale data?
17. Deployment to production
Production is an operating model. Before deploying, define runtime ownership, configuration, secrets, health checks, observability, scaling, backups, rollback, and incident response.
Container example
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
USER node
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]
Adapt output paths to the framework. Minimize the runtime image, run as non-root, scan dependencies/images, and do not bake secrets into layers or frontend bundles.
Expose separate health concepts:
- liveness: the process is not irrecoverably stuck;
- readiness: this instance may receive traffic;
- startup: initialization is still allowed to continue.
Observability
Capture metrics, structured logs, distributed traces, and errors. Propagate request/trace IDs browser → edge → application → database calls. Monitor user-facing service-level indicators such as successful request rate and latency, then define objectives and alert on actionable symptoms. CPU alone rarely tells whether users are suffering.
Operational checklist
- DNS, TLS, security headers, and trusted proxy settings are correct;
- environment variables are validated;
- database connections fit the global database limit across replicas;
- migrations are compatible with mixed versions;
- static assets use content hashes and long immutable caching;
- HTML/data responses have appropriate private/public cache control;
- backups exist and restore has been rehearsed;
- rate limits and body limits are configured;
- graceful termination completes within the platform deadline;
- source maps are uploaded securely to error tracking;
- alerts name an owner and a runbook;
- rollback or roll-forward has been exercised;
- post-deploy smoke tests cover the critical path.
Recall: Can this release coexist with the previous version? How do I know users are healthy? When did we last restore a backup rather than merely create one?
18. The complete TaskFlow request
Use this sequence to connect all chapters:
- The browser requests
/projects/p1?status=todo; the URL owns the filter. - The route authenticates the session and loads only projects visible to that user.
- The Server Component or route loader reads project/tasks in parallel where possible.
- HTML streams with useful skeleton boundaries; only interactive islands ship client code.
- The design-system form uses native labels, typed variants, and visible focus.
- Submission reaches an Action or route action.
- Runtime schema validation treats
FormDataas untrusted. - Authorization checks membership for project
p1, not merely session existence. - A transaction inserts the task and audit/outbox record under database constraints.
- A precise cache tag/path is invalidated.
- Optimistic UI reconciles with the canonical server result or visibly rolls back.
- Structured logs and a trace connect the click to SQL time.
- Unit, integration, authorization, database, and critical E2E tests protect the behavior.
- CI builds one immutable artifact and verifies types, tests, and production build.
- A compatible migration and canary release precede full traffic.
- Real-user metrics confirm latency, errors, and layout stability.
19. TypeScript patterns worth memorizing
satisfies checks without widening away useful inference
const routes = {
dashboard: "/app",
settings: "/app/settings",
} satisfies Record<string, `/${string}`>;
Exhaustiveness check
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}
function statusLabel(status: Task["status"]): string {
switch (status) {
case "todo": return "To do";
case "doing": return "In progress";
case "done": return "Done";
default: return assertNever(status);
}
}
Generic component with constrained keys
type Column<T> = {
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => React.ReactNode;
};
function Table<T extends { id: React.Key }>({
rows,
columns,
}: {
rows: readonly T[];
columns: readonly Column<T>[];
}) {
return (
<table>
<thead><tr>{columns.map(c => <th key={String(c.key)}>{c.header}</th>)}</tr></thead>
<tbody>
{rows.map(row => (
<tr key={row.id}>
{columns.map(column => (
<td key={String(column.key)}>
{column.render
? column.render(row[column.key], row)
: String(row[column.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
Use generics when a relationship between inputs and outputs matters. Do not use merely to avoid naming a type.
Result for expected domain outcomes
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
type InviteError = "ALREADY_MEMBER" | "PROJECT_FULL" | "INVALID_EMAIL";
function invite(email: string): Result<{ invitationId: string }, InviteError> {
// Domain implementation
return { ok: true, value: { invitationId: crypto.randomUUID() } };
}
Do not replace every thrown exception with Result. It works well for expected, enumerable outcomes; infrastructure failures can still throw and be handled at a boundary.
20. Revision drills
Build drills
- Create a task board whose status filter lives in search params. Refresh and share the URL.
- Implement creation with
useActionState, pending UI, schema validation, and a typed result. - Add optimistic completion with rollback on a simulated
409 Conflict. - Create a route error boundary that distinguishes
404,403, and unexpected errors. - Build an accessible dialog with focus management and Escape behavior; test it by keyboard.
- Replace a prop-drilled theme with a narrow typed Context, then profile rerenders.
- Implement a reducer-based editor state machine and prove impossible transitions are ignored.
- Add an Express endpoint backed by Drizzle and a real PostgreSQL integration test.
- Add tenant authorization tests where two users know the same resource ID but only one has access.
- Localize task counts, money, an instant, and a date-only value in three locales.
- Create a CI pipeline with an intentionally failing type check, test, and build; learn the failure output.
- Measure an N+1 route, fix it, and record query count and p95 latency before and after.
Explain aloud without notes
- RSC versus SSR versus Server Functions.
- why runtime validation remains necessary with TypeScript.
- why authentication is not authorization.
- when state belongs in the URL.
- why an effect cleanup must mirror setup.
- why cache invalidation is part of mutation correctness.
- when a reducer is clearer than several state variables.
- what React Router's three modes add.
- why database constraints still matter with Zod and an ORM.
- the difference between liveness and readiness.
- why build-once/promote is safer than rebuilding per environment.
- how to prove a performance optimization helped users.
Code-review questions
Correctness: What invariant is assumed? What happens concurrently?
Trust: Which values came from the client? Were they validated?
Security: Is authorization scoped to this resource and tenant?
State: Is there exactly one source of truth?
React: Is an effect doing work that belongs in render or an event?
Types: Can invalid states be represented? Is unknown narrowed?
UX/a11y: Can this be completed with keyboard and assistive technology?
Failure: What does the user see? What does the operator see?
Data: Is the mutation transactional? Which caches become stale?
Scale: Is the query bounded and indexed for its access path?
Tests: Which observable contract would catch a regression?
Delivery: Can old/new versions coexist and can we recover safely?
21. Compact mastery checklist
I am production-ready when I can consistently:
- model UI and domain state with discriminated unions;
- keep server-only code and secrets outside the client graph;
- choose server/client boundaries based on interactivity and payload;
- validate and authorize every mutation at the trusted boundary;
- name the cache layer, key, lifetime, and invalidation rule;
- use effects only for synchronization with external systems;
- keep navigational state in the URL and remote state near its data layer;
- design accessible primitives with semantic HTML;
- use router loaders/actions/fetchers intentionally;
- implement forms that remain understandable during failure and pending states;
- organize by feature with controlled dependency direction;
- separate identity, role, resource, and tenant policy;
- understand the SQL and transactions produced by the ORM;
- localize complete messages, formats, zones, and direction;
- test user behavior, security boundaries, and real database contracts;
- build an immutable artifact through a least-privilege pipeline;
- optimize measured bottlenecks and verify real-user impact;
- deploy compatibly, observe health, and rehearse recovery.
Official references for ongoing revision
- React 19 release and Actions
- React Server Components
use serverand Server Function securityuseActionState- React: You Might Not Need an Effect
- React Router: choosing Declarative, Data, or Framework mode
- React Router data loading
- Auth.js
- Drizzle ORM documentation
- Express documentation
- PostgreSQL documentation
- TypeScript documentation
- Testing Library guiding principles
- Web Content Accessibility Guidelines
- web.dev performance guidance
Suggested spaced-repetition schedule
- Day 0: read one section, type its examples, complete one drill.
- Day 1: explain the section aloud without looking.
- Day 3: rebuild the core example from a blank file.
- Day 7: review it through security, accessibility, testing, and performance lenses.
- Day 14: combine it with another section in TaskFlow.
- Day 30: answer the recall questions and repair the weakest area.
Final capstone: prove the knowledge
Build one vertical slice before expanding TaskFlow: an authenticated user opens a project, filters tasks, creates one, and marks it complete. Set a strict constraint that every layer must be visible and explainable. Draw the request path, define domain types, write the runtime schemas, create the database constraints, implement authorization, expose the server operation, connect the route and form, and add accessible pending, success, and failure states.
Before writing UI code, define three acceptance examples: an editor succeeds, a viewer receives a safe denial, and malformed input never reaches the insert. Then implement the smallest pure domain functions and unit-test them. Add a database integration test for the transaction and tenant condition. Add a component test that submits the form through the public UI, and one end-to-end test for the critical journey. Deliberately break authorization and confirm a test fails; a green test that never goes red may protect nothing.
Instrument the slice with a request ID, structured server log, mutation duration, and database timing. Load a realistic number of tasks, capture a baseline, and inspect the query plan. Run the interface with keyboard only, zoom to 200%, enable reduced motion, simulate a slow network, and force the server to return 400, 403, 409, and 500. The goal is not merely to see the happy path work; it is to know how every expected and unexpected state behaves.
Finally, deploy the slice to a non-production environment using the same immutable artifact intended for production. Apply a backward-compatible migration, run a smoke test, observe logs and metrics, and rehearse rollback. Write a one-page retrospective answering: Which boundary was weakest? Which assumption escaped the type system? Which failure was hardest to diagnose? What would become painful at ten times the traffic or team size? Turn those answers into the next revision session. Mastery is the ability to predict behavior, gather evidence when the prediction fails, and improve the system without losing its guarantees.
