Fetching Data & Network States
Learning Objectives
- You can fetch and check remote data without confusing HTTP and network failures.
- You can represent loading, success, empty, and error outcomes explicitly.
- You can test remote-data UI behavior and prevent stale request results from replacing newer state.
A local array exists immediately. Remote data may arrive later, arrive empty, fail validation, or never arrive. A useful interface represents those situations deliberately instead of assuming that every request produces a nonempty array.
In the practice application (practice/), we will extend the explicit Load todos pattern from Part 1 using the same neutral /api/todos endpoint. It returns rows with id and name. This laboratory version adds response decoding, a single state union, and cancellation on /lab; preserve the original TodoList at /. The marketplace project later adapts these ideas to marketplace reads.
Fetch has two important steps
const response = await fetch(`${baseUrl}/api/todos`);if (!response.ok) throw new Error(`HTTP ${response.status}`);const data = await response.json();The first await obtains a response. The second reads and decodes its body. A 404 or 500 normally resolves to a Response; it does not automatically reject the fetch promise. Network failures can reject it, and invalid JSON can fail during body decoding. See the Fetch guide.
Do not use an empty successful array as a universal replacement for errors. “No records exist” and “we do not know whether records exist” are different facts.
A fulfilled 503 response
0 / 5 points
await fetch(url) resolves to a Response whose status is 503. What should a
read helper do before using the success data?
Put one read operation in a small module
Create client/src/lib/examples/todos-api.ts:
export type Todo = { id: number; name: string };
export const decodeTodos = (value: unknown): Todo[] => { if (!Array.isArray(value)) throw new Error('Expected a todo array'); return value.map((row: unknown) => { if (typeof row !== 'object' || row === null || !('id' in row) || !('name' in row) || typeof row.id !== 'number' || !Number.isInteger(row.id) || row.id <= 0 || typeof row.name !== 'string') { throw new Error('Invalid todo response'); } return { id: row.id, name: row.name }; });};
export const getTodos = async (signal?: AbortSignal): Promise<Todo[]> => { const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'; const response = await fetch(`${baseUrl}/api/todos`, { signal }); if (!response.ok) throw new Error(`Todo request failed (${response.status})`); return decodeTodos(await response.json());};The local decoder makes a distinction that a TypeScript assertion cannot: it checks values while the program runs. as Todo[] would only tell the type checker what to assume. Part 3 introduces schema validation rather than expanding handwritten checks indefinitely.
The helper reads the same public API-base variable as Part 1. Do not replace it with an internal container host name for normal browser use. The E2E environment already supplies a different address because its browser runs on the Compose network.
Represent mutually exclusive outcomes
Create client/src/lib/examples/TodosPanel.svelte:
<script lang="ts"> import { onMount } from 'svelte'; import { getTodos, type Todo } from './todos-api';
type LoadState = | { kind: 'idle' } | { kind: 'loading' } | { kind: 'ready'; todos: Todo[] } | { kind: 'error'; message: string };
let state = $state<LoadState>({ kind: 'idle' }); let generation = 0; let controller: AbortController | undefined;
const load = async () => { const requestNumber = ++generation; controller?.abort(); const current = new AbortController(); controller = current; state = { kind: 'loading' }; try { const todos = await getTodos(current.signal); if (requestNumber !== generation || current.signal.aborted) return; state = { kind: 'ready', todos }; } catch (error) { if (requestNumber !== generation || current.signal.aborted) return; state = { kind: 'error', message: error instanceof Error ? error.message : 'Could not load todos' }; } };
onMount(() => () => { generation += 1; controller?.abort(); });</script>
<section aria-label="Stored todos"> <h2>Stored todos</h2> <button type="button" onclick={load}>Load todos</button> {#if state.kind === 'idle'} <p>Load the current todos.</p> {:else if state.kind === 'loading'} <p role="status">Loading todos...</p> {:else if state.kind === 'error'} <p role="alert">{state.message}</p> {:else if state.todos.length === 0} <p>No stored todos.</p> {:else} <ul> {#each state.todos as todo (todo.id)} <li>{todo.name}</li> {/each} </ul> {/if}</section>The union gives each state an explicit shape. Loading cannot simultaneously contain an old error because that field belongs to another variant. An empty array is a successful ready result, not a network failure.
Mount this component on /lab. Loading remains explicit, as in Part 1. The new onMount hook registers cleanup when the component is created; it does not start this component’s request. Later, if a page should load immediately, call the same named load function from a synchronous onMount callback and return the cancellation cleanup from that callback.
Prevent old responses from replacing new ones
The user may reload while a previous request is still running. Request A might then finish after request B. Without a guard, older data can overwrite newer data.
The request number identifies the currently relevant request. Aborting the previous request avoids unnecessary client work where possible; the generation check protects the state update even when cancellation is too late. This does not mean an aborted mutation was undone on the server. In this chapter we only cancel reads.
The onMount callback returns cleanup for component removal. It is deliberately not an async callback: an async function returns a promise, not the cleanup function Svelte expects. The lifecycle documentation explains this distinction.
Two pending station requests
0 / 5 points
A user requests station A, then station B. B completes first. A completes later even though its AbortSignal has been aborted. Which state update is valid?
Test success and failure without a live API
Create TodosPanel.test.ts beside the component:
import { cleanup, render, screen } from '@testing-library/svelte';import { userEvent } from '@testing-library/user-event';import { afterEach, expect, it, vi } from 'vitest';import TodosPanel from './TodosPanel.svelte';
afterEach(() => { cleanup(); vi.unstubAllGlobals();});
it('renders returned rows', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response( JSON.stringify([{ id: 31, name: 'Review notes' }]), { status: 200, headers: { 'Content-Type': 'application/json' } } ))); const user = userEvent.setup(); render(TodosPanel); await user.click(screen.getByRole('button', { name: 'Load todos' })); expect(await screen.findByText('Review notes')).toBeInTheDocument();});
it('does not present an HTTP failure as an empty result', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Unavailable', { status: 503 }))); const user = userEvent.setup(); render(TodosPanel); await user.click(screen.getByRole('button', { name: 'Load todos' })); expect(await screen.findByRole('alert')).toHaveTextContent('503'); expect(screen.queryByText('No stored todos.')).not.toBeInTheDocument();});These tests substitute the browser API, not a real network. They establish component response handling but do not check DNS, CORS, or Hono. Add a test for [], a rejected fetch promise, and invalid response data. For a loading-state test, control when a promise resolves rather than sleeping for a guessed duration. Vitest documents global stubbing.
CORS is a browser access boundary
The practice application’s client and API have different origins. The API’s CORS middleware supplies response headers authorizing browser access from the configured client origin. curl is not a browser enforcing that policy, so a direct request can work while a browser cannot expose the response to JavaScript.
For some cross-origin requests, the browser first sends a preflight request. A simple read may not need one. Do not set mode: 'no-cors' as a fix: an opaque response does not give your JavaScript the JSON body it needs. Do not disable browser security or treat CORS as authorization. Part 5 supplies actual identity and access rules. See the CORS guide.
Browser and command-line observations
0 / 5 points
curl http://localhost:8000/api/todos returns JSON, but a page at
http://localhost:5173 cannot expose the same response to JavaScript and
the browser reports a CORS error. Which response fits this evidence?
Use browser fetch for this first network boundary
For this chapter’s programming exercise, implement the request with browser fetch, not hc or a framework-managed remote function. Inspect the actual request in the Network panel. This is an implementation requirement, not a restriction on sources of help. Part 4 will refactor the same kind of operation to a typed client after its runtime behavior is familiar.
A local type declaration is useful documentation, but assigning await response.json() to that type does not inspect the bytes. Keep the distinction between the value received and the type the program expects.
An asserted weather value
0 / 5 points
An HTTP response contains {"station":"Harbour","readings":"missing"}.
A program writes const value = await response.json() as Weather.
What has the assertion established at runtime?
Read a different response shape
The next two exercises use weather readings rather than todos or marketplace listings. First write the read module and its decoder. The interface exercise then uses a supplied correct read module rather than files from your submission, so its implementation focus remains state and request lifetime. The API exercise is still listed as a prerequisite for the interface exercise.
The weather contract needs two numeric checks that the todo example did not.
typeof temperatureC === 'number' also accepts NaN and infinities, so combine
that check with Number.isFinite(temperatureC). For an unknown id, first
establish that it is a number, then require Number.isSafeInteger(id) && id > 0.
A negative finite temperature remains valid.
Check a weather response before using it
0 / 20 points
Task
Complete src/lib/weather/decode.ts and src/lib/weather/api.ts. The remote
shape differs from the todo array:
{"station":"Harbour","readings":[{"id":17,"temperatureC":12.5}]}Requirements
decodeWeather(value: unknown) returns { station, readings }. Require a string
station containing at least one non-whitespace character, and a readings array.
Each reading needs a positive safe integer id and a finite numeric
temperatureC; negative temperatures are valid. An empty array is valid. Reject
invalid values by throwing an Error. Return only the named fields, preserve the
station text and reading order, and do not mutate input. Unique IDs are a
precondition for the subsequent panel.
getWeather(signal?) uses browser fetch to request /weather-readings from
import.meta.env.VITE_API_BASE_URL, falling back to http://localhost:8000.
Pass the supplied signal. Reject non-successful HTTP responses with an Error
whose message includes their status. Parse and decode successful JSON. Do not
convert HTTP failures, malformed JSON, or invalid data into an empty result.
Do not use a real weather service or a typed client.
Submit
Submit only the two named files at their src/lib/weather/ paths. Four checks
award 5 points each: valid decoding, rejection and projection of data, request
construction, and failure handling. Tests supply fetch responses; no server
route or account is needed.
Recover a networked interface
Keep the load action available for retry. Test transitions in one mounted component, including a previously successful read followed by empty data or a failure. A late response from an old request must not replace the latest result.
Show the latest weather request
0 / 25 points
Task
Complete src/lib/WeatherPanel.svelte. The grader supplies the weather decoder
and getWeather(signal?) from the preceding task under src/lib/weather/.
Import those helpers; do not submit replacements. This is a separate exercise
starter, not the marketplace project and not a dependency on files from an
earlier submission.
Requirements
Initially show Load the latest readings. without making a request. A button
named Load readings starts a read and remains enabled so another read can
replace it. While pending, show Loading readings... in a paragraph with
role="status" and
remove old results/errors. On success show the station as a heading and each
reading as <temperature> °C. With no readings show No readings recorded..
On failure show an alert including the error message; no old rows or empty-success
message may remain. A successful retry clears the error.
Only the latest requested result may update the view. Abort superseded reads, retain a request-identity guard, and abort work when the component is removed. An older promise may still resolve in the tests even after abort: it must not replace newer output. Keep this browser read explicit; no Hono route is needed.
Submit
Submit only src/lib/WeatherPanel.svelte. Five checks award 5 points each:
initial/pending, successful/empty output, error and recovery transitions,
out-of-order responses, and cleanup. Tests control promises rather than waiting
for a real weather service.
Check Your Understanding
- Why must successful fetch completion and successful HTTP status be checked separately?
- What does the decoder establish that a TypeScript assertion does not?
- Why is an empty result not an appropriate replacement for every error?
- How can an older response overwrite a newer one, and how is that prevented here?
- What does a stubbed component test fail to establish about CORS or connectivity?