Frontend Engineering

React and TypeScript Production Mastery: Complete Revision Notes

Afzal AhmedFaz Ahmed
·28 July 2026·48 min read
React 19TypeScriptReact Router 7Auth.jsNode.jsExpressPostgreSQLDrizzle ORMTestingCI/CD

Why This Matters

My practical, code-heavy revision handbook for React 19, TypeScript, Server Components, Actions, state, hooks, React Router 7, forms, Auth.js, Node.js, PostgreSQL, Drizzle ORM, testing, CI/CD, performance and production deployment.

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:

  1. The browser: input values, open dialogs, selected tabs, optimistic UI.
  2. The URL: route, search, filters, pagination, shareable view state.
  3. The server: authenticated session, business rules, cached responses.
  4. The database: durable source of truth.
Most architectural problems come from putting state in the wrong place or duplicating it. A search filter that should survive refresh belongs in the URL, not only in 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.
RSC is a capability supplied by a framework and bundler integration, not something I should hand-roll. React says the component feature is stable in React 19, while the lower-level bundler/framework APIs may change between minor releases. Pin versions when building infrastructure around them.
// 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

  1. State the observed and expected behavior.
  2. Reproduce with the smallest stable input.
  3. Identify the first layer where reality diverges: event, state, request, response, database, render.
  4. Inspect evidence: React DevTools, Network panel, server log, trace, SQL.
  5. Form one falsifiable hypothesis.
  6. Change one variable or add one observation.
  7. Add a regression test after fixing.
Logs should be structured and correlated:
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:

StateDefault home
Input, toggle, dialogcomponent useState
Complex local transition logicuseReducer
Shareable filter/pageURL search params
Theme/current localeContext
Remote durable recordsrouter/framework loader or query cache
Cross-feature client workflowexternal store, only when justified
Do not store derived data:
// 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, and memo everywhere;
  • boolean-prop explosions such as primary, danger, compact, quiet that 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.
Use stable domain keys:
{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.
Pick deliberately. A small embedded SPA may want Declarative mode. A business application usually benefits from Data mode. A full React application that wants server rendering and route conventions may prefer Framework mode.
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.
Prefer established authentication libraries over inventing token or password flows. Password storage, OAuth state/PKCE, cookies, session rotation, and account linking contain sharp edges.

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.
Avoid testing implementation details such as internal state or private methods. Query the DOM as a user would, preferably by accessible role and name.
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:

  1. expand: add a nullable column/table/index;
  2. deploy code that writes old and new shapes if required;
  3. backfill safely in batches;
  4. switch reads;
  5. remove the old shape in a later release.
Do not deploy code expecting a column before the column exists. Avoid long blocking migrations at peak traffic. Test migrations against production-like size and take verified backups.

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.
Do not make liveness depend on every downstream service or a temporary database outage may restart all instances simultaneously. Readiness can reflect dependencies required to serve safely.

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.
Deployment is not complete when the command exits successfully. It is complete when health signals show the new version serving real traffic within expected error and latency bounds.

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:

  1. The browser requests /projects/p1?status=todo; the URL owns the filter.
  2. The route authenticates the session and loads only projects visible to that user.
  3. The Server Component or route loader reads project/tasks in parallel where possible.
  4. HTML streams with useful skeleton boundaries; only interactive islands ship client code.
  5. The design-system form uses native labels, typed variants, and visible focus.
  6. Submission reaches an Action or route action.
  7. Runtime schema validation treats FormData as untrusted.
  8. Authorization checks membership for project p1, not merely session existence.
  9. A transaction inserts the task and audit/outbox record under database constraints.
  10. A precise cache tag/path is invalidated.
  11. Optimistic UI reconciles with the canonical server result or visibly rolls back.
  12. Structured logs and a trace connect the click to SQL time.
  13. Unit, integration, authorization, database, and critical E2E tests protect the behavior.
  14. CI builds one immutable artifact and verifies types, tests, and production build.
  15. A compatible migration and canary release precede full traffic.
  16. Real-user metrics confirm latency, errors, and layout stability.
If any arrow is vague, that is a revision target.

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

  1. Create a task board whose status filter lives in search params. Refresh and share the URL.
  2. Implement creation with useActionState, pending UI, schema validation, and a typed result.
  3. Add optimistic completion with rollback on a simulated 409 Conflict.
  4. Create a route error boundary that distinguishes 404, 403, and unexpected errors.
  5. Build an accessible dialog with focus management and Escape behavior; test it by keyboard.
  6. Replace a prop-drilled theme with a narrow typed Context, then profile rerenders.
  7. Implement a reducer-based editor state machine and prove impossible transitions are ignored.
  8. Add an Express endpoint backed by Drizzle and a real PostgreSQL integration test.
  9. Add tenant authorization tests where two users know the same resource ID but only one has access.
  10. Localize task counts, money, an instant, and a date-only value in three locales.
  11. Create a CI pipeline with an intentionally failing type check, test, and build; learn the failure output.
  12. 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.
The deepest pattern across all chapters is boundary discipline. Types guard compile-time boundaries. Schemas guard runtime input. Server/client boundaries control capability and JavaScript. Feature boundaries control dependencies. Transactions protect data invariants. Error boundaries contain failure. CI/CD boundaries control change. Strong React engineering is the practice of making each boundary explicit, narrow, observable, and testable.

Official references for ongoing revision

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.
Reading creates recognition; retrieval and building create mastery.

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.

Applied In

The thinking in this article has been applied throughout my enterprise portfolio, where architecture, workflows, permissions, notifications, reporting and modular design are all built around real business operations rather than isolated technical features.

View Continuous Learning →

Use this journal entry for recall practice

Compare your explanation with the questions and working answers in my Practice Room.

Practise React, TypeScript and frontend questions →
Afzal Ahmed

Faz Ahmed

Senior Full Stack Engineer & Technical Lead

A hands-on engineer with 15+ years in commercial software. I publish what I am studying, revising and testing so visitors can see both established experience and learning still in progress.

How would you approach this problem? I'd love to hear your thoughts or continue the discussion.

Connect on LinkedIn →