Free Handbook · Runs in your browser

JavaScript + Tools

Nine mini-labs on how JavaScript is actually used at work: Node.js servers, npm scripts and tooling, an Express API, a React component, TypeScript on top, PostgreSQL from Node, Docker, tests with Jest, and Git — each with the real code and where to go deeper.

0 / 111 lessons🔥 0 day streak
ShareXLinkedIn

Module 14 · what you'll be able to do

  • Write a Node.js HTTP server and understand what Express adds on top
  • Set up a project with npm scripts, ESLint and Prettier that a team would accept
  • Build a React component with state and props and know what React is doing
  • Add TypeScript to JavaScript incrementally and read a type error
  • Query PostgreSQL from Node safely, with parameters and a pool
  • Containerise a Node app, test it with Jest, and use Git the way a JS team does
01

JavaScript + Node.js

Node.js is V8 plus a standard library for servers: files, networking, processes, streams. The mental model from Module 06 is the whole story — one thread, an event loop, never block. A Node HTTP server is twelve lines, and every framework (Express, Fastify, Next.js) is a layer over exactly this.

JavaScript + Node.js

A JSON API with nothing but Node

The request handler runs for every request on the same thread; anything slow must be async. Note the three things every server does: parse the URL, branch on method + path, and write a status, headers and body.

javascript
import { createServer } from "node:http"
import { readFile } from "node:fs/promises"

const tasks = [{ id: 1, text: "learn node", done: false }]

const server = createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`)
  const json = (status, body) => {
    res.writeHead(status, { "Content-Type": "application/json" })
    res.end(JSON.stringify(body))
  }

  if (req.method === "GET" && url.pathname === "/api/tasks") return json(200, tasks)

  if (req.method === "POST" && url.pathname === "/api/tasks") {
    let body = ""
    for await (const chunk of req) body += chunk            // the body is a stream
    const { text } = JSON.parse(body)
    const task = { id: tasks.length + 1, text, done: false }
    tasks.push(task)
    return json(201, task)
  }

  if (req.method === "GET" && url.pathname === "/") {
    res.writeHead(200, { "Content-Type": "text/html" })
    return res.end(await readFile("index.html"))
  }

  json(404, { error: "not found" })
})

server.listen(3000, () => console.log("http://localhost:3000"))

// node --watch server.js   restarts on change
// curl -X POST localhost:3000/api/tasks -H "content-type: application/json" -d '{"text":"ship"}'
The event loop this server runs on — Module 06 →
Node vs Deno vs Bun
Same language, same idea. Deno and Bun add TypeScript out of the box, faster startup and built-in tooling; Node has the ecosystem and the jobs. Learn Node; the others take an afternoon after that.
02

JavaScript + npm scripts, ESLint, Prettier

A JavaScript project is defined by its package.json scripts. The four every project has: dev, test, lint, build. ESLint catches bugs (unused variables, ==, missing await); Prettier ends formatting arguments. Set both up on day one — teams reject pull requests that fail them.

JavaScript + npm

A project skeleton a team would accept

The flat ESLint config (eslint.config.js) is the current format. lint-staged with a git hook runs the linter only on the files you changed, so a commit is never blocked by someone else's mess.

javascript
// package.json
{
  "name": "tasks-api",
  "type": "module",
  "scripts": {
    "dev": "node --watch --env-file=.env src/server.js",
    "start": "node src/server.js",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "eslint . && prettier --check .",
    "format": "prettier --write .",
    "prepare": "husky"
  },
  "devDependencies": { "eslint": "^9", "prettier": "^3", "vitest": "^3", "husky": "^9", "lint-staged": "^15" },
  "lint-staged": { "*.{js,ts}": ["eslint --fix", "prettier --write"] }
}

// eslint.config.js
import js from "@eslint/js"
export default [
  js.configs.recommended,
  {
    languageOptions: { ecmaVersion: "latest", sourceType: "module", globals: { console: "readonly", process: "readonly" } },
    rules: { "no-unused-vars": "warn", eqeqeq: "error", "no-var": "error", "prefer-const": "error" },
  },
]

// .prettierrc
{ "semi": false, "singleQuote": true, "printWidth": 100 }

// .gitignore
node_modules/
dist/
.env
*.log
package.json and semver — Module 05 →
03

JavaScript + Express

Express is the thin, twenty-year-old layer most Node APIs are built on: routing (app.get("/path")), middleware (functions that run on every request — logging, auth, JSON parsing) and error handling. Learn it even if you end up on Fastify, Hono or Next.js route handlers; they all use its vocabulary.

JavaScript + Express

The tasks API in Express, with validation and errors

Compare with the raw Node version: routing, body parsing and 404s are handled; what you write is the business logic. The error middleware (four arguments) is where every thrown error lands — one place to log and shape the response. Express 5 forwards rejected promises from async handlers to it automatically.

javascript
import express from "express"

const app = express()
app.use(express.json())                                    // parse JSON bodies
app.use((req, res, next) => { console.log(req.method, req.url); next() })   // middleware: logging

const tasks = new Map([[1, { id: 1, text: "learn express", done: false }]])
let nextId = 2

class HttpError extends Error { constructor(status, message) { super(message); this.status = status } }

app.get("/api/tasks", (req, res) => {
  const { done } = req.query                                // ?done=true
  const list = [...tasks.values()].filter(t => done === undefined || String(t.done) === done)
  res.json(list)
})

app.get("/api/tasks/:id", (req, res) => {
  const task = tasks.get(Number(req.params.id))
  if (!task) throw new HttpError(404, "task not found")
  res.json(task)
})

app.post("/api/tasks", async (req, res) => {
  const { text } = req.body ?? {}
  if (typeof text !== "string" || !text.trim()) throw new HttpError(400, "text is required")
  const task = { id: nextId++, text: text.trim(), done: false }
  tasks.set(task.id, task)
  res.status(201).json(task)
})

app.patch("/api/tasks/:id", (req, res) => {
  const task = tasks.get(Number(req.params.id))
  if (!task) throw new HttpError(404, "task not found")
  Object.assign(task, { done: Boolean(req.body.done) })
  res.json(task)
})

// Error handler: four arguments, registered LAST
app.use((err, req, res, next) => {
  const status = err.status ?? 500
  if (status === 500) console.error(err)
  res.status(status).json({ error: status === 500 ? "internal error" : err.message })
})

app.listen(3000, () => console.log("http://localhost:3000"))
Custom error classes and where to catch — Module 07 →
04

JavaScript + React

React is the state → render loop from Module 10 made systematic: a component is a function from props and state to UI; when state changes, React re-runs the function and updates only the DOM nodes that differ. JSX is HTML-looking syntax that compiles to function calls. Every rule in React — immutable state, keys, hooks at the top level — follows from "the function will be called again".

JavaScript + React

The todo app as a React component

Notice what is not here: no querySelector, no addEventListener, no manual DOM updates. You describe the UI for the current state; React reconciles. useState is the closure from Module 04 that survives re-renders; useEffect is for side effects (fetching, subscriptions) after render.

jsx
import { useState, useEffect } from "react"

function TaskList() {
  const [tasks, setTasks] = useState([])          // state: React re-renders when it changes
  const [text, setText] = useState("")
  const [loading, setLoading] = useState(true)

  useEffect(() => {                                // side effect: load once after the first render
    fetch("/api/tasks").then(r => r.json()).then(data => { setTasks(data); setLoading(false) })
  }, [])

  async function add(e) {
    e.preventDefault()
    const res = await fetch("/api/tasks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) })
    const task = await res.json()
    setTasks(prev => [...prev, task])              // immutable update: a NEW array
    setText("")
  }

  function toggle(id) {
    setTasks(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t))
  }

  if (loading) return <p>Loading…</p>

  return (
    <section>
      <form onSubmit={add}>
        <input value={text} onChange={e => setText(e.target.value)} placeholder="What needs doing?" />
        <button disabled={!text.trim()}>Add</button>
      </form>
      <ul>
        {tasks.map(t => (
          <li key={t.id} className={t.done ? "done" : ""}>      {/* key: how React matches items across renders */}
            <input type="checkbox" checked={t.done} onChange={() => toggle(t.id)} />
            {t.text}
          </li>
        ))}
      </ul>
      <p>{tasks.filter(t => !t.done).length} left</p>
    </section>
  )
}

export default TaskList

// npm create vite@latest my-app -- --template react   → a running project in a minute
The same app without React — Module 10 →
Next.js
Next.js is React plus a server: file-based routing, server components that render on the server, API route handlers, and deployment on Vercel. Most React jobs today are Next.js jobs. This site is built on it.
05

JavaScript + TypeScript

TypeScript is JavaScript with types that are checked before the code runs and erased after. Everything in this handbook is valid TypeScript; you add annotations where they pay off — function signatures, API shapes, data models — and the compiler catches the "Cannot read properties of undefined" class of bugs at edit time. Most professional JavaScript is now written in TypeScript.

JavaScript + TypeScript

Typing the tasks API, and reading a type error

Three ideas cover 90% of daily TypeScript: interfaces for object shapes, union types for "one of these", and generics for "works with any type but keeps track of which". The error at the bottom is the one you will see most: you forgot that a value can be undefined.

typescript
// types.ts
export interface Task {
  id: number
  text: string
  done: boolean
  tags?: string[]                    // optional
}
export type Status = "all" | "active" | "done"         // a union: only these three strings

// A generic function: works for any T, returns the same T
function firstOr<T>(items: T[], fallback: T): T {
  return items[0] ?? fallback
}

function visible(tasks: Task[], status: Status): Task[] {
  if (status === "all") return tasks
  return tasks.filter(t => t.done === (status === "done"))
}

const tasks: Task[] = [{ id: 1, text: "learn ts", done: false }]
const first = firstOr(tasks, { id: 0, text: "", done: false })   // first: Task
visible(tasks, "active")
visible(tasks, "pending")
// error TS2345: Argument of type '"pending"' is not assignable to parameter of type 'Status'.

function find(id: number): Task | undefined {
  return tasks.find(t => t.id === id)
}
console.log(find(1).text)
// error TS18048: 'find(1)' is possibly 'undefined'.
//   → the bug from Module 11 #1, caught before it runs. Fix: find(1)?.text

// Adopting it: npx tsc --init, rename .js → .ts one file at a time, start with "strict": false, tighten later.
// Or keep .js and add // @ts-check at the top with JSDoc types — checking without a build step.
TypeScript handbook — coming to the programming hub →
06

JavaScript + PostgreSQL

Every backend job touches a database. pg is the standard PostgreSQL driver for Node: a pool of connections, query(sql, params) with $1 placeholders — never string concatenation — and rows back as plain objects. ORMs (Prisma, Drizzle) sit on top; learn the driver first so you can read the SQL they generate.

JavaScript + PostgreSQL

Parameterised queries, transactions, and the pool

The $1 placeholders send parameters separately from the SQL, which is what makes injection impossible. A transaction needs one client for all its statements; the finally { client.release() } is not optional — a leaked client eventually exhausts the pool and every request hangs.

javascript
import pg from "pg"                                      // npm install pg
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 })

export async function listTasks(done) {
  const { rows } = await pool.query(
    "SELECT id, text, done FROM tasks WHERE ($1::boolean IS NULL OR done = $1) ORDER BY id",
    [done ?? null],
  )
  return rows
}

export async function createTask(text) {
  const { rows: [task] } = await pool.query(
    "INSERT INTO tasks (text) VALUES ($1) RETURNING id, text, done",
    [text],
  )
  return task
}

// Several statements that must succeed or fail together: one client, one transaction
export async function moveTask(id, fromList, toList) {
  const client = await pool.connect()
  try {
    await client.query("BEGIN")
    await client.query("DELETE FROM list_items WHERE list_id = $1 AND task_id = $2", [fromList, id])
    await client.query("INSERT INTO list_items (list_id, task_id) VALUES ($1, $2)", [toList, id])
    await client.query("COMMIT")
  } catch (err) {
    await client.query("ROLLBACK")
    throw err
  } finally {
    client.release()
  }
}

// NEVER: pool.query(`SELECT * FROM tasks WHERE text = '${text}'`)   ← SQL injection
Learn the SQL itself — SQL Mastery, runnable in the browser →
07

JavaScript + Docker

Docker packages your app with its exact Node version and dependencies so it runs the same on a laptop, in CI and on a server — the cure for Module 11's "works on my machine". A Node Dockerfile has a standard shape; the multi-stage form keeps dev dependencies out of the image you ship.

JavaScript + Docker

A production Dockerfile and compose for the API + Postgres

Copy package*.json first so the npm ci layer is cached until dependencies change. Run as a non-root user. compose.yaml starts the database alongside, with the connection string the driver above reads.

docker
# Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]

# compose.yaml
services:
  api:
    build: .
    ports: ["3000:3000"]
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/tasks
    depends_on: [db]
  db:
    image: postgres:16
    environment: { POSTGRES_USER: app, POSTGRES_PASSWORD: secret, POSTGRES_DB: tasks }
    volumes: ["pgdata:/var/lib/postgresql/data"]
volumes:
  pgdata:

# docker compose up --build
# docker compose exec db psql -U app tasks
Containers in a data platform — the Data Engineering course →
08

JavaScript + Jest / Vitest

Tests are how you change code without fear. Jest defined the API (describe, test, expect, mocks); Vitest is the same API, faster, with native ESM — pick Vitest for new projects, read Jest everywhere else. The habit that matters: test behaviour through the public interface, and test the async and error paths, not just the happy one.

JavaScript + Jest

Testing the reducer, the API and a mocked fetch

Three kinds of test from this handbook's code: a pure function (fast, no setup), an HTTP endpoint (supertest spins the app up in-process), and a function that calls fetch (mocked so the test is deterministic and offline). toEqual compares structure; toBe compares identity.

javascript
import { describe, test, expect, vi, beforeEach } from "vitest"     // or from "@jest/globals"
import request from "supertest"
import { reducer } from "../src/state.js"
import { app } from "../src/app.js"

describe("reducer", () => {
  test("adds a task and advances the id", () => {
    const next = reducer({ tasks: [], nextId: 1 }, { type: "add", text: "ship" })
    expect(next.tasks).toEqual([{ id: 1, text: "ship", done: false }])
    expect(next.nextId).toBe(2)
  })
  test("does not mutate the previous state", () => {
    const prev = { tasks: [], nextId: 1 }
    reducer(prev, { type: "add", text: "x" })
    expect(prev.tasks).toHaveLength(0)
  })
})

describe("POST /api/tasks", () => {
  test("creates a task", async () => {
    const res = await request(app).post("/api/tasks").send({ text: "test it" })
    expect(res.status).toBe(201)
    expect(res.body).toMatchObject({ text: "test it", done: false })
  })
  test("rejects empty text", async () => {
    const res = await request(app).post("/api/tasks").send({})
    expect(res.status).toBe(400)
    expect(res.body.error).toMatch(/required/)
  })
})

describe("loadTasks", () => {
  beforeEach(() => { vi.restoreAllMocks() })
  test("throws on a non-OK response", async () => {
    vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 500, json: async () => ({}) })
    const { loadTasks } = await import("../src/client.js")
    await expect(loadTasks()).rejects.toThrow("500")
  })
})

// npx vitest run --coverage
Why the async tests need await and rejects — Module 06 →
09

JavaScript + Git

Git is the same for every language; what is JavaScript-specific is what you must not commit (node_modules, .env, build output), what you must (package-lock.json), and the hooks that run the linter before a commit lands. The workflow below is what nearly every JavaScript team runs.

JavaScript + Git

The daily loop and the JavaScript-specific rules

Small branches, small commits, a pull request with CI green. The .gitignore at the top is the one that matters; npm ci in CI is what makes the lockfile meaningful.

bash
# .gitignore — the JavaScript essentials
node_modules/
dist/
.next/
coverage/
.env
.env.*
!.env.example
*.log
.DS_Store

# The daily loop
git switch -c feat/task-filters          # a branch per change
npm ci                                    # exact versions from the lockfile
# …edit, run tests…
git add -p                                # stage hunks, review your own diff
git commit -m "feat(api): filter tasks by done status"
git push -u origin feat/task-filters      # open a PR; CI runs lint + tests
git switch main && git pull --rebase       # after merge, update main

# .github/workflows/ci.yml — what CI runs on every PR
name: ci
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm run lint
      - run: npm test

# Two habits: never commit .env (rotate the secret if you did — it is in the history forever),
# and commit package-lock.json so everyone installs the same tree.
Case-sensitive paths and the other CI-only failures — Module 11 →
Mid-levelWhat is the difference between npm install and npm ci, and which should CI use?

npm install resolves versions from package.json ranges and may update the lockfile; npm ci installs exactly what package-lock.json says, deletes node_modules first, and fails if the lockfile and package.json disagree. CI and Docker builds should use npm ci — it is faster and reproducible. Developers use npm install when adding or updating a dependency.

Finish the JavaScript handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.