Client API Modules & Mutations
Learning Objectives
- You can implement HTTP mutations behind a reusable client API module with a stable component-facing interface.
- You can interpret a documented JSON response and handle empty responses separately.
- You can update a parent-owned collection from an accepted API result.
In Part 2, we moved a read request into a small API module. We can use the same idea for mutations. The component collects intent; the module constructs the request and interprets the response. Let’s use a note application.
Use the practice application (practice-typed-http/) for a notes page that calls a real Hono API. We will extend this same page through validation, typed requests, debugging, and browser testing. The bookmark exercise supplies a separate editor for transfer practice.
The notes API contract
Our notes API accepts POST /api/notes with { title }. Success returns 201 and
{ note: { id, title, version } }. GET /api/notes returns 200 and { notes: [...] }.
The API trims titles, requires 1-120 characters, rejects unknown fields, and stores
accepted notes in PostgreSQL. An invalid title returns 400 INVALID_INPUT;
a title already stored returns 409 TITLE_EXISTS. Errors use
{ error: { code, message, fields? } }. The database supplies the ID and version.
Keep the method, status, envelope, and field names together when following a request through the application.
Inspect the supplied notes API
The typed HTTP APIs walking skeleton
already contains the complete notes API. Open api/src/routes/notes-routes.ts,
api/src/schemas/note-schema.ts, and
api/src/repositories/notes-repository.ts. They follow Part 3’s route, schema,
and repository responsibilities. The only migration is
database-migrations/001_practice_notes.sql; startup applies it automatically.
The starter already connects the routes to PostgreSQL in api/src/app.ts:
import { createNotesApp } from "./routes/notes-routes.ts";import { createNotesRepository } from "./repositories/notes-repository.ts";
app.route("/", createNotesApp(createNotesRepository(sql)));The repository’s list() reads stored notes. Its create(title) inserts a note
and returns its stored ID, normalized title, and version; a duplicate title
returns no new row. The route turns those outcomes into the documented HTTP
responses. Open the schema too: the server trims the title and rejects unknown
fields before calling the repository.
With the starter running, inspect a read from a second terminal:
curl -i http://localhost:8000/api/notesUse curl.exe on native Windows PowerShell. An empty database returns
{"notes":[]}; notes created through either complete example appear in this
response. The supplied api/tests/notes-routes_test.ts uses unique test data,
checks the real database, and removes the rows it owns. Run it in the isolated
project:
docker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml run --rm database-migrationsdocker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml run --build --rm api deno test --cached-only --allow-env --allow-net tests/notes-routes_test.tsNow build your own browser operation and form on /lab. The complete
/examples/fetch page remains a reference: its operation is in
client/src/lib/examples/notes/fetch-note-api.ts. We will construct the request
in small steps, then refactor the same operation in Chapter 4.5.
Implement a JSON POST operation
Create client/src/lib/api/note-api.ts. Its types describe the documented
response contract. For this example, assume successful JSON and documented error
bodies follow that contract:
export type Note = { id: number; title: string; version: number };type NoteResponse = { note: Note };export const createNote = async ( baseUrl: string, title: string, transport: typeof fetch = fetch,): Promise<Note> => { const response = await transport(`${baseUrl}/api/notes`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), });
if (response.status !== 201) { throw new Error(`The save was not accepted (${response.status})`); }
const body: NoteResponse = await response.json(); return body.note;};First follow the request: POST constructs the proposed title; 201 identifies an accepted creation; the body supplies the server’s note. The type annotation expresses the documented contract and does not validate response bytes.
A network failure rejects fetch, while HTTP errors still return a Response.
Reading invalid JSON also rejects. The UI must handle a rejected operation and
must not interpret it as proof that no server write occurred.
Connect a form to its operation
Keep HTTP details in the API module. The form calls an operation, shows pending feedback, and displays the returned note; it does not need each header or endpoint path.
Create client/src/lib/components/NoteForm.svelte. A prop supplies the operation,
so a page can connect the API module while a test controls its outcome:
<script lang="ts"> import type { Note } from "#lib/api/note-api.ts";
let { saveNote, onaccepted = () => {} }: { saveNote: (title: string) => Promise<Note>; onaccepted?: (note: Note) => void; } = $props(); let pending = $state(false); let message = $state("");
const submit = async (event: SubmitEvent & { currentTarget: HTMLFormElement }) => { event.preventDefault(); if (pending) return; const data = new FormData(event.currentTarget); const proposedTitle = String(data.get("title") ?? "").trim(); pending = true; message = "Submitting note..."; try { const accepted = await saveNote(proposedTitle); onaccepted(accepted); message = `Accepted: ${accepted.title}`; } catch { message = "The note could not be confirmed. Keep your text and check the result."; } finally { pending = false; } };</script>
<form onsubmit={submit}> <label for="note-title">Title</label> <input id="note-title" name="title" required /> <button type="submit" disabled={pending}>Submit note</button> <p role="status">{message}</p></form>The handler captures the submitted value before await. Pending state changes
before calling the operation, and finally restores the control after either
outcome. Only the returned record supplies the accepted title. No branch clears
the editable text, including text changed while the operation was pending.
The optional callback lets a parent react to acceptance; we connect it below.
Replace client/src/routes/lab/+page.svelte with this page. It uses the default
fetch transport and the API base URL configured in the starter:
<script lang="ts"> import NoteForm from "#lib/components/NoteForm.svelte"; import { createNote } from "#lib/api/note-api.ts";
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; const saveNote = (title: string) => createNote(baseUrl, title);</script>
<h1>Notes</h1><NoteForm {saveNote} />At /lab, submit a new title such as Maps for Friday. Inspect Network: a POST to
/api/notes should return 201 with the stored note’s ID, title, and version.
The form displays that returned title and keeps your input. Submit the same title
again: observe 409 and preserved input. The form currently gives general failure
feedback; Chapter 4.4 will display the API’s field message.
The request now crosses the browser/API boundary. If it returns 404, check the
route mount; if the browser blocks the response, check the existing CORS origin
and VITE_API_BASE_URL. A controlled transport is still useful in a test, where
we deliberately choose the response without making a network request.
Preserve documented error details
This complete replacement keeps status/code/message information for a component that needs specific feedback.
export type Note = { id: number; title: string; version: number };type NoteResponse = { note: Note };type ErrorResponse = { error: { code: string; message: string } };
export class ApiError extends Error { constructor(public status: number, public code: string, message: string) { super(message); }}
export const createNote = async ( baseUrl: string, title: string, transport: typeof fetch = fetch,): Promise<Note> => { const response = await transport(`${baseUrl}/api/notes`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), });
let body: NoteResponse | Partial<ErrorResponse>; try { body = await response.json(); } catch { throw new ApiError(response.status, "UNEXPECTED_RESPONSE", "Invalid JSON response"); }
if (response.status !== 201) { const { error } = body as Partial<ErrorResponse>; throw new ApiError( response.status, error?.code ?? "UNEXPECTED_RESPONSE", error?.message ?? "The save could not be confirmed", ); }
return (body as NoteResponse).note;};The try block handles body decoding. The status check then distinguishes an
accepted note from an error response. ApiError preserves the received status and
application code for the component. Its types describe the assumed response
contract; they do not validate responses at runtime.
PATCH and DELETE
A 204 response must not be parsed with response.json(). A delete operation can check the status and return undefined. For PATCH, send only the editable fields and the version the user actually edited. Do not send the entire local object back, including server-controlled fields.
A 409 is a completed HTTP response. The server may be saying that the state changed after the user loaded it. Repeating the same stale update automatically does not resolve that disagreement.
The Fetch API documentation provides the underlying request and response behavior. Our API module adds application-specific interpretation.
A missing response after deletion
0 / 5 points
A DELETE request commits, but the connection closes before the browser receives a response. Which statement can the client make from the rejected fetch alone?
Our running notes example implements creation and reading. To transfer the pattern to the bookmark exercise, a rename sends an explicit editable payload and expected version:
const response = await transport(`${baseUrl}/api/bookmarks/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, version }),});This fragment belongs inside a rename operation; id, title, and version
are its arguments. For deletion, branch on the empty success before reading a body:
const response = await transport(`${baseUrl}/api/bookmarks/${id}`, { method: "DELETE" });if (response.status === 204) return;// Interpret the documented error body here; successful deletion has no JSON.The bookmark starter supplies creation, shared response parsing, the ApiError
class, and a factory that binds the base URL and transport once. Complete only
versioned renaming and empty-response deletion. Keep field selection explicit
in the PATCH request, return the accepted record, and reuse the supplied error
handling.
Complete bookmark editing and deletion
0 / 20 points
Files in the editor
The editor below contains one file. Edit src/lib/api/bookmark-api.ts.
Keep the supplied types and ApiError class. The grader passes a controlled transport function to the factory; there is no transport implementation in another editor file.
Task
The starter already implements POST creation and the shared JSON/error parser.
Keep them working. Complete only rename(id,{title,version}) and remove(id)
in src/lib/api/bookmark-api.ts; the factory, types and ApiError are supplied.
Rename sends PATCH /api/bookmarks/:id with JSON Content-Type and only the
editable title and expected version. Do not spread extra caller fields. Require
200 and return the server’s bookmark through the supplied readBookmark helper.
Removal sends DELETE /api/bookmarks/:id. Require 204 and return undefined
without parsing its empty body. For another status, use the supplied error
handling. A documented rejection keeps status/code/message; unreadable JSON
becomes UNEXPECTED_RESPONSE. Propagate a transport rejection unchanged and
never retry a mutation automatically. Base URLs have no trailing slash and
supplied IDs are valid.
Success/error JSON has the documented fields; no runtime output validator, server, local application, or student-authored test suite is required. Versioned renaming and empty deletion are worth 10 points each. The checks also protect the supplied creation and error behavior without awarding extra points.
Check and submit
Choose Submit solution below the editor to run the grading checks and read the feedback. The editor submits the file above. There is no separate Run preview. Grading does not require a local application. Keep file paths and exported names as specified; additional observations belong in your own notes.
Update the notes list after a save
Add a read operation to the end of note-api.ts so reloading the page can fetch
what the API has stored:
export const getNotes = async ( baseUrl: string, transport: typeof fetch = fetch, signal?: AbortSignal,): Promise<Note[]> => { const response = await transport(`${baseUrl}/api/notes`, { signal }); if (!response.ok) throw new ApiError(response.status, "READ_FAILED", "Notes could not be loaded"); const body: { notes: Note[] } = await response.json(); return body.notes;};As with the creation response, this annotation describes the documented contract; it does not validate arbitrary response bytes. Keep the earlier marketplace read decoders when working on that separate project.
A form often changes data displayed by another component. Reuse Part 2’s ownership
rule: their common parent owns the confirmed collection. Native controls retain
editable input while mounted; the form owns its pending flag and feedback. Its
onaccepted callback tells the parent about a returned record; it does not create
another copy of the collection.
Create client/src/lib/components/NoteList.svelte:
<script lang="ts"> import type { Note } from "#lib/api/note-api.ts"; let { notes }: { notes: Note[] } = $props();</script>
<h2>Confirmed notes</h2><ul aria-label="Confirmed notes"> {#each notes as note (note.id)} <li>{note.title}</li> {/each}</ul>Then create client/src/lib/components/NotesPanel.svelte as their common parent:
<script lang="ts"> import { onMount } from "svelte"; import type { Note } from "#lib/api/note-api.ts"; import NoteForm from "#lib/components/NoteForm.svelte"; import NoteList from "#lib/components/NoteList.svelte";
let { saveNote, initialNotes = [] }: { saveNote: (title: string) => Promise<Note>; initialNotes?: Note[]; } = $props(); let notes = $state<Note[]>([]); onMount(() => { notes = [...initialNotes]; }); const accepted = (note: Note) => { notes = [...notes, note]; };</script>
<NoteForm {saveNote} onaccepted={accepted} /><p>Confirmed notes: {notes.length}</p><NoteList {notes} />Now replace /lab’s page with this complete version. It reads stored notes
before mounting the panel; accepted creations then update the parent’s collection.
<script lang="ts"> import { onMount } from "svelte"; import NotesPanel from "#lib/components/NotesPanel.svelte"; import { createNote, getNotes, type Note } from "#lib/api/note-api.ts";
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; const saveNote = (title: string) => createNote(baseUrl, title); let notes = $state<Note[] | null>(null); let message = $state(""); onMount(() => { const controller = new AbortController(); getNotes(baseUrl, fetch, controller.signal) .then((rows) => { notes = rows; }) .catch((error) => { if (!controller.signal.aborted) message = error instanceof Error ? error.message : "Notes could not be loaded"; }); return () => controller.abort(); });</script>
<h1>Notes</h1>{#if notes !== null} <NotesPanel {saveNote} initialNotes={notes} />{:else if message} <p role="alert">{message}</p> <button onclick={() => location.reload()}>Retry loading notes</button>{:else} <p role="status">Loading notes...</p>{/if}Submit two new titles. Each accepted record adds one row and updates the count from the same parent-owned array. Submit a duplicate: the rows and count stay unchanged and the form keeps your text. Reload the page. Its GET should restore the stored rows, including their original IDs. Restarting only the API also preserves them because PostgreSQL stores the records.
Figure 1 traces the accepted POST into the parent-owned view and the later GET that restores stored notes after a reload.
A parent variable holds the current browser view; it does not provide persistence.
Keep this /lab page for the following chapters. A failed creation response can
leave an uncertain result; Chapter 4.6 explains why a fresh read matters before
submitting again.
Test the outgoing request and incoming result
Use the transport argument to supply a controlled response. In this test,
http://example.test is a dummy API address. The supplied mock replaces fetch
and returns our prepared response without contacting a server.
import { expect, test, vi } from "vitest";import { createNote } from "./note-api";
test("creates a note through the documented POST", async () => { const transport = vi.fn<typeof fetch>().mockResolvedValue( new Response( JSON.stringify({ note: { id: 17, title: "Read chapter", version: 1 } }), { status: 201, headers: { "Content-Type": "application/json" } }, ), ); const note = await createNote("http://example.test", "Read chapter", transport); expect(note).toEqual({ id: 17, title: "Read chapter", version: 1 }); const [input, options] = transport.mock.calls[0]; const request = new Request(input, options); expect(request.url).toBe("http://example.test/api/notes"); expect(request.method).toBe("POST"); expect(await request.json()).toEqual({ title: "Read chapter" });});This test establishes the module’s behavior against a controlled transport. Constructing a Request lets it inspect the same HTTP contract whether the operation passes a URL and options or a Request object. The supplied API test and the browser observation above check different boundaries.
Save this as client/src/lib/api/note-api.test.ts, beside note-api.ts. Keep it
as the request-contract check when we later replace explicit fetch construction
with a typed caller.
Extend note-api.test.ts with a rejection case, keeping the createNote import:
test("preserves a documented rejection", async () => { const transport = vi.fn<typeof fetch>().mockResolvedValue(new Response( JSON.stringify({ error: { code: "INVALID_INPUT", message: "Enter a title" } }), { status: 400, headers: { "Content-Type": "application/json" } }, )); await expect(createNote("http://example.test", "", transport)).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT", message: "Enter a title", });});
test("keeps a missing response distinct from a documented rejection", async () => { const failure = new TypeError("Connection lost"); const transport = vi.fn<typeof fetch>().mockRejectedValue(failure); await expect(createNote("http://example.test", "Maps", transport)).rejects.toBe(failure);});Also arrange a response containing unreadable JSON and expect
UNEXPECTED_RESPONSE with its received status. Neither a decoding failure nor a
transport failure proves that the server left storage unchanged. Run these cases
together with the component check below.
Check the connection between components
Create client/src/lib/components/NotesPanel.test.ts. Supply a returned title
that differs from the proposal so the check detects a parent that displays its
own input instead of the accepted record:
import { afterEach, expect, test, vi } from "vitest";import { cleanup, render, screen } from "@testing-library/svelte";import userEvent from "@testing-library/user-event";import type { Note } from "#lib/api/note-api.ts";import NotesPanel from "./NotesPanel.svelte";
afterEach(cleanup);
test("shares the accepted record with the parent-owned list", async () => { const saveNote = vi.fn<(title: string) => Promise<Note>>() .mockResolvedValue({ id: 17, title: "MAPS", version: 1 }); render(NotesPanel, { props: { saveNote } }); const user = userEvent.setup(); const title = screen.getByLabelText("Title"); await user.type(title, "Maps"); await user.click(screen.getByRole("button", { name: "Submit note" })); expect(await screen.findByText("MAPS", { selector: "li" })).toBeVisible(); expect(screen.getByText("Confirmed notes: 1")).toBeVisible(); expect(screen.getAllByRole("listitem")).toHaveLength(1);});Run the request and component tests together, then the explicit checker:
docker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml run --build --rm --no-deps client deno task testdocker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml run --build --rm --no-deps client deno task checkThis pattern prepares us for publication and bidding: a child reports acceptance, and the parent replaces the corresponding confirmed record. Those workflows need no application-wide store.
Check Your Understanding
- Why is a received HTTP error different from a network failure?
- Why does a 204 response need special treatment?
- Which responsibilities belong to the API module, the form, and the parent-owned collection?
- Why should an update send an editable-field allowlist instead of spreading the whole client object?