Forms & Mutations

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:

Terminal window
curl -i http://localhost:8000/api/notes

Use 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:

Terminal window
docker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml run --rm database-migrations
docker 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.ts

Now 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.

Figure 1. Accepted records update the browser view; reloading retrieves stored notes through the API.

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:

Terminal window
docker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml run --build --rm --no-deps client deno task test
docker compose -p wsd-typed-http-test -f compose.yaml -f compose.test.yaml run --build --rm --no-deps client deno task check

This 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

  1. Why is a received HTTP error different from a network failure?
  2. Why does a 204 response need special treatment?
  3. Which responsibilities belong to the API module, the form, and the parent-owned collection?
  4. Why should an update send an editable-field allowlist instead of spreading the whole client object?