Skip to content

Data from an API

Assignment

Martina Hostettler wants the requests to reach the association.

As a volunteer at the counter, I want to see the members' requests on the association's server, so that we can get the things ready.

The volunteer adds that the catalog should come from the server as well, so that a return at the counter reaches everyone. The acceptance criteria are:

  1. The catalog comes from the course API. The app shows that it is loading and reports errors clearly.
  2. A request goes to the course API, and the app shows its answer, a refusal included.
  3. The overview "My requests" loads from the course API and cancels the loading when someone leaves the page before it is done.

Learning goals

After this chapter you can

  • read the routes of the course API and start the API,
  • load data in an action and show loading, error and empty states,
  • send data as JSON and evaluate the answers of the API,
  • abort a request with AbortController,
  • set the address of the API and explain when CORS is needed.

The small server of the earlier parts could only deliver the catalog. From this part on, the course API in the folder api of the course repo plays the association's server. It is a Node program without dependencies and runs in a terminal of its own.

Terminal window
cd api
npm start

After that it answers at http://localhost:3000. Every path starts with /de or /en, which chooses the language of the data and of the messages. The answers are JSON, and an error comes as { "error": "…" }.

Method and pathWhoAnswer
GET /itemseveryoneall things
GET /items/:ideveryoneone thing, 404 for an unknown number
POST /sessioneveryonesigns in with { "number": "M-117" }, 401 for an unknown number
GET /sessioneveryonethe signed-in member, 401 without a login
DELETE /sessioneveryonesigns out
GET /reservationsmembersthe own requests
POST /reservationsmembersa request, 201, 422 with a message per field, 409 for days already requested
DELETE /reservations/:idmemberswithdraws an own request
POST /items/:id/returnteamrecords a return, 403 without a team account

Like the small server, the API waits two seconds with ?delay=2000 and answers with that status for ?status=503. It keeps returns and requests in api/storage/state.json, and npm run reset starts over.

An action of the store loads the data from the server. Next to the data, the store keeps the status of the request, and every page shows something for every status. fetch only rejects when no answer arrives at all, and the action checks a status like 503 itself, as in chapter 1.4.

App.vue

App.vue
<script setup>
import { ref } from 'vue'
import { useCatalogStore } from './catalog.js'
const catalog = useCatalogStore()
const address = ref('/api/items')
catalog.load(address.value)
</script>
<template>
<p>
<label for="address">Address</label>
<select id="address" v-model="address" @change="catalog.load(address)">
<option value="/api/items">normal</option>
<option value="/api/items?delay=3000">slow</option>
<option value="/api/items?status=503">with an error</option>
</select>
</p>
<p v-if="catalog.status === 'loading'" role="status">Loading the catalog …</p>
<div v-else-if="catalog.status === 'error'" role="alert">
<p>{{ catalog.error }}</p>
<button type="button" @click="catalog.load(address)">Try again</button>
</div>
<ul v-else>
<li v-for="thing in catalog.items" :key="thing.id">{{ thing.name }}</li>
</ul>
</template>

catalog.js

catalog.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useCatalogStore = defineStore('catalog', () => {
const items = ref([])
// idle, loading, error or ready, the page shows something for each of them
const status = ref('idle')
const error = ref('')
async function load(address = '/api/items') {
status.value = 'loading'
try {
const response = await fetch(address)
// fetch only fails when there is no answer, a status like 503 has to be checked
if (!response.ok) throw new Error(`The course API answers with the status ${response.status}.`)
items.value = await response.json()
status.value = 'ready'
} catch (problem) {
error.value = problem.message
status.value = 'error'
}
}
return { items, status, error, load }
})

main.js

main.js
// The stand-in for the course API comes first, before any component calls fetch
import './mock-api.js'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')

mock-api.js

mock-api.js
// A stand-in for the course API inside the preview of the playground, which has no server.
// It answers fetch calls below /api/ like the course API and passes every other call on.
import { things } from './things.js'
const realFetch = window.fetch.bind(window)
const requests = []
function answer(status, body) {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
// Waits like a slow network, an aborted signal ends the wait as it ends a real request
function wait(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason)
const timer = setTimeout(resolve, ms)
signal?.addEventListener('abort', () => {
clearTimeout(timer)
reject(signal.reason)
})
})
}
window.fetch = async (input, options = {}) => {
const url = new URL(input, 'http://localhost')
if (!url.pathname.startsWith('/api/')) return realFetch(input, options)
const path = url.pathname.slice('/api'.length)
const method = options.method ?? 'GET'
// One thing takes between 200 and 1200 milliseconds, everything else 500, ?delay sets the time
const random = path.startsWith('/items/') ? 200 + Math.random() * 1000 : 500
await wait(Number(url.searchParams.get('delay') ?? random), options.signal)
const status = Number(url.searchParams.get('status') ?? 200)
if (status !== 200) return answer(status, { error: `The course API answers with the status ${status}.` })
if (method === 'GET' && path === '/items') return answer(200, things)
const item = /^\/items\/(\d+)$/.exec(path)
if (method === 'GET' && item !== null) {
const thing = things.find((entry) => entry.id === Number(item[1]))
return thing ? answer(200, thing) : answer(404, { error: 'This thing does not exist.' })
}
if (method === 'GET' && path === '/reservations') return answer(200, requests)
if (method === 'POST' && path === '/reservations') {
// The stand-in acts as if M-117 were signed in
const { itemId, from = '', to = '', note = '' } = JSON.parse(options.body)
const fields = {}
if (from === '') fields.from = 'Choose the first day.'
if (to === '') fields.to = 'Choose the last day.'
if (Object.keys(fields).length > 0) return answer(422, { error: 'The request contains errors.', fields })
if (requests.some((other) => other.itemId === itemId && other.from <= to && from <= other.to)) {
return answer(409, { error: 'The thing is already requested for some of these days.' })
}
const request = { id: `R-${requests.length + 1}`, itemId, member: 'M-117', from, to, note }
requests.push(request)
return answer(201, request)
}
return answer(404, { error: 'The course API does not know this address.' })
}

things.js

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Drill', deposit: 20, availableFrom: null },
{ id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null },
{ id: 6, name: 'Projector', deposit: 80, availableFrom: null },
{ id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },
]

The playground has no server. That's why the file mock-api.js plays the course API. It intercepts calls of fetch below /api/ and answers like the API, with ?delay and ?status. In a project it goes away, and the real API answers.

A request goes to the API with method: 'POST'. Its content sits as JSON in body, and the Content-Type header tells the API that it is JSON. The course API only accepts JSON and answers with 415 otherwise.

App.vue

App.vue
<script setup>
import { reactive, ref } from 'vue'
import { useRequestsStore } from './requests.js'
const requests = useRequestsStore()
requests.load()
const form = reactive({ from: '', to: '', note: '' })
const result = ref({ fields: {} })
const sending = ref(false)
async function submit() {
sending.value = true
result.value = await requests.send({ itemId: 6, ...form })
sending.value = false
}
</script>
<template>
<h1>Reserve the projector</h1>
<form @submit.prevent="submit">
<label for="from">First day</label>
<input id="from" v-model="form.from" type="date">
<span v-if="result.fields.from" class="error">{{ result.fields.from }}</span>
<label for="to">Last day</label>
<input id="to" v-model="form.to" type="date">
<span v-if="result.fields.to" class="error">{{ result.fields.to }}</span>
<button :disabled="sending">{{ sending ? 'Sending …' : 'Send request' }}</button>
</form>
<p v-if="result.error" role="alert" class="error">{{ result.error }}</p>
<p v-if="result.reservation" role="status">Request {{ result.reservation.id }} is sent.</p>
<h2>My requests</h2>
<p v-if="requests.list.length === 0">No requests yet.</p>
<ul v-else>
<li v-for="request in requests.list" :key="request.id">{{ request.id }}: {{ request.from }} to {{ request.to }}</li>
</ul>
</template>
<style scoped>
form {
display: grid;
gap: 0.25rem;
max-width: 20rem;
}
.error {
color: #a3341f;
}
</style>

main.js

main.js
// The stand-in for the course API comes first, before any component calls fetch
import './mock-api.js'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')

mock-api.js

mock-api.js
// A stand-in for the course API inside the preview of the playground, which has no server.
// It answers fetch calls below /api/ like the course API and passes every other call on.
import { things } from './things.js'
const realFetch = window.fetch.bind(window)
const requests = []
function answer(status, body) {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
// Waits like a slow network, an aborted signal ends the wait as it ends a real request
function wait(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason)
const timer = setTimeout(resolve, ms)
signal?.addEventListener('abort', () => {
clearTimeout(timer)
reject(signal.reason)
})
})
}
window.fetch = async (input, options = {}) => {
const url = new URL(input, 'http://localhost')
if (!url.pathname.startsWith('/api/')) return realFetch(input, options)
const path = url.pathname.slice('/api'.length)
const method = options.method ?? 'GET'
// One thing takes between 200 and 1200 milliseconds, everything else 500, ?delay sets the time
const random = path.startsWith('/items/') ? 200 + Math.random() * 1000 : 500
await wait(Number(url.searchParams.get('delay') ?? random), options.signal)
const status = Number(url.searchParams.get('status') ?? 200)
if (status !== 200) return answer(status, { error: `The course API answers with the status ${status}.` })
if (method === 'GET' && path === '/items') return answer(200, things)
const item = /^\/items\/(\d+)$/.exec(path)
if (method === 'GET' && item !== null) {
const thing = things.find((entry) => entry.id === Number(item[1]))
return thing ? answer(200, thing) : answer(404, { error: 'This thing does not exist.' })
}
if (method === 'GET' && path === '/reservations') return answer(200, requests)
if (method === 'POST' && path === '/reservations') {
// The stand-in acts as if M-117 were signed in
const { itemId, from = '', to = '', note = '' } = JSON.parse(options.body)
const fields = {}
if (from === '') fields.from = 'Choose the first day.'
if (to === '') fields.to = 'Choose the last day.'
if (Object.keys(fields).length > 0) return answer(422, { error: 'The request contains errors.', fields })
if (requests.some((other) => other.itemId === itemId && other.from <= to && from <= other.to)) {
return answer(409, { error: 'The thing is already requested for some of these days.' })
}
const request = { id: `R-${requests.length + 1}`, itemId, member: 'M-117', from, to, note }
requests.push(request)
return answer(201, request)
}
return answer(404, { error: 'The course API does not know this address.' })
}

requests.js

requests.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useRequestsStore = defineStore('requests', () => {
const list = ref([])
async function load() {
const response = await fetch('/api/reservations')
if (response.ok) list.value = await response.json()
}
// Sends a request as JSON and returns what the API said about it
async function send(request) {
const response = await fetch('/api/reservations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})
const body = await response.json()
if (!response.ok) return { error: body.error, fields: body.fields ?? {} }
list.value.push(body)
return { reservation: body, fields: {} }
}
return { list, load, send }
})

things.js

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Drill', deposit: 20, availableFrom: null },
{ id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null },
{ id: 6, name: 'Projector', deposit: 80, availableFrom: null },
{ id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },
]

The status of the answer tells how things turned out. 201 means created, 422 means that fields are wrong, and 409 reports a conflict, which for the course API means days that are already requested. The form already checks the rules of the association before sending, as it has since part 3. The API checks them once more anyway, because anyone can send a request without the app, and only the server knows about conflicts with other members' requests.

The course API simulates the login with the member number and knows no password. After POST /session it sets a cookie, and the browser sends it along with every further request to the same address. That's how the API knows who sends a request. The store of the login doesn't see this cookie, it only keeps the member from the answer.

auth.js
// The course API checks the member number and sets the session cookie.
// Returns the message of the API for a failed login and an empty text when it worked.
async function signIn(number) {
const { ok, data } = await callApi('/session', { method: 'POST', body: { number } })
if (!ok) return data.error
member.value = data
return ''
}

Now the server protects the team area as well. The API refuses a return without a team account with 403, no matter what the guard in the browser shows. Chapter 7.4 explains why a cookie suits the login better than the browser's storage.

Chapter 5.2 skipped outdated answers with a cleanup function. With an AbortController from chapter 1.4, the same cleanup function aborts the old request instead of only skipping its answer.

App.vue

App.vue
<script setup>
import { onWatcherCleanup, ref, watch } from 'vue'
import { things } from './things.js'
const index = ref(0)
const thing = ref(null)
watch(
index,
async (now) => {
const id = things[now].id
const controller = new AbortController()
// Runs before the next change of index, the old request is cancelled
onWatcherCleanup(() => controller.abort())
try {
const response = await fetch(`/api/items/${id}`, { signal: controller.signal })
thing.value = await response.json()
console.log('Answer for', id)
} catch (problem) {
if (problem.name !== 'AbortError') throw problem
console.log('Cancelled', id)
}
},
{ immediate: true },
)
function next() {
index.value = (index.value + 1) % things.length
}
</script>
<template>
<button type="button" @click="next">Next thing</button>
<p>Chosen: {{ things[index].name }}</p>
<h1>{{ thing?.name ?? '…' }}</h1>
</template>

main.js

main.js
// The stand-in for the course API comes first, before any component calls fetch
import './mock-api.js'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')

mock-api.js

mock-api.js
// A stand-in for the course API inside the preview of the playground, which has no server.
// It answers fetch calls below /api/ like the course API and passes every other call on.
import { things } from './things.js'
const realFetch = window.fetch.bind(window)
const requests = []
function answer(status, body) {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
// Waits like a slow network, an aborted signal ends the wait as it ends a real request
function wait(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason)
const timer = setTimeout(resolve, ms)
signal?.addEventListener('abort', () => {
clearTimeout(timer)
reject(signal.reason)
})
})
}
window.fetch = async (input, options = {}) => {
const url = new URL(input, 'http://localhost')
if (!url.pathname.startsWith('/api/')) return realFetch(input, options)
const path = url.pathname.slice('/api'.length)
const method = options.method ?? 'GET'
// One thing takes between 200 and 1200 milliseconds, everything else 500, ?delay sets the time
const random = path.startsWith('/items/') ? 200 + Math.random() * 1000 : 500
await wait(Number(url.searchParams.get('delay') ?? random), options.signal)
const status = Number(url.searchParams.get('status') ?? 200)
if (status !== 200) return answer(status, { error: `The course API answers with the status ${status}.` })
if (method === 'GET' && path === '/items') return answer(200, things)
const item = /^\/items\/(\d+)$/.exec(path)
if (method === 'GET' && item !== null) {
const thing = things.find((entry) => entry.id === Number(item[1]))
return thing ? answer(200, thing) : answer(404, { error: 'This thing does not exist.' })
}
if (method === 'GET' && path === '/reservations') return answer(200, requests)
if (method === 'POST' && path === '/reservations') {
// The stand-in acts as if M-117 were signed in
const { itemId, from = '', to = '', note = '' } = JSON.parse(options.body)
const fields = {}
if (from === '') fields.from = 'Choose the first day.'
if (to === '') fields.to = 'Choose the last day.'
if (Object.keys(fields).length > 0) return answer(422, { error: 'The request contains errors.', fields })
if (requests.some((other) => other.itemId === itemId && other.from <= to && from <= other.to)) {
return answer(409, { error: 'The thing is already requested for some of these days.' })
}
const request = { id: `R-${requests.length + 1}`, itemId, member: 'M-117', from, to, note }
requests.push(request)
return answer(201, request)
}
return answer(404, { error: 'The course API does not know this address.' })
}

things.js

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Drill', deposit: 20, availableFrom: null },
{ id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null },
{ id: 6, name: 'Projector', deposit: 80, availableFrom: null },
{ id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },
]

When you click "Next thing" quickly several times, the console reports the cancelled requests, and only the last answer appears. A call of fetch that was aborted rejects with an AbortError, and the page skips it. As in chapter 1.4, AbortSignal.timeout aborts a request that takes too long.

The projects of this part read the address of the API from VITE_API_URL and otherwise use /api/en. The Vite proxy from chapter 6.2 forwards everything below /api to the course API. For the browser, the app and the API share one address that way.

.env.example
# Address of the course API. Copy this file to .env.local to change it on your machine.
# Only variables that start with VITE_ reach the browser, never put a secret into one.
VITE_API_URL=/api/en
The path of a request A sequence from top to bottom. A component calls an action of the store, and the store sends fetch to /api/de/items. Vite on port 5173 takes the request and forwards everything below /api to the course API on port 3000, which answers GET /de/items and keeps returns and requests in storage/state.json. The answer takes the same way back. On the right, a dashed path leads from the store directly to http://localhost:3000. That path goes to another origin and therefore needs CORS. Component calls an action Store fetch('/api/en/items') Vite on port 5173 proxy, forwards /api Course API on port 3000 GET /en/items storage/state.json returns and requests directly to localhost:3000 only with CORS

If the app calls the API directly at http://localhost:3000/en, the request goes to another port and thus to another origin. According to MDN, the browser restricts such requests from scripts. It only hands out the answer when the server allows the origin of the page with the Access-Control-Allow-Origin header. For requests with JSON, the browser first asks with OPTIONS whether the method and the headers are allowed. A simple request still gets processed by the server, and the browser only withholds the answer. The course API allows the addresses of Vite on the ports 5173 and 4173. With cookies, it also takes credentials: 'include' in fetch and the server's permission, which is why the projects stick with the proxy.

Work with the examples of this chapter. Read the code and write down what you expect before you change anything.

  1. In the first example, choose "slow" and right afterwards "with an error". Which state stays in the end?
  2. In the example with the request, send the same days twice. What does the stand-in for the API answer the second time?
  3. In the same example, remove the Content-Type header. The stand-in doesn't notice, but what would the course API answer?
  4. In the example on aborting, remove the line with onWatcherCleanup and click quickly several times. Which thing ends up as the heading?

The exercises build the catalog and the requests from the assignment. You'll find the project in the course repo in the folder exercises/07-state/api/en. The store of the login already talks to the course API. Copy the folder, install the packages and start the tests. The tests need no server, and for the app in the browser you start the course API in a second terminal.

Terminal window
npm install
npm test

Exercise Getting started

The store catalog still reads the catalog from a file. Load it in the action load from ${API}/items. The state status is loading, error or ready, and on an error error holds a message such as "The course API answers with the status 503.".

Solution
catalog.js
export const useCatalogStore = defineStore('catalog', () => {
const list = ref([])
// loading, error or ready, the pages show something for each of them
const status = ref('loading')
const error = ref('')
async function load() {
status.value = 'loading'
try {
const response = await fetch(`${API}/items`)
// fetch only fails without an answer, a status such as 503 has to be checked
if (!response.ok) throw new Error(`The course API answers with the status ${response.status}.`)
list.value = await response.json()
status.value = 'ready'
} catch (problem) {
error.value = problem instanceof TypeError ? 'The course API cannot be reached.' : problem.message
status.value = 'error'
}
}

Without an answer, fetch rejects with a TypeError, and for that the solution reports that the course API cannot be reached. App calls load on start, and the catalog page shows something for every status.

Exercise Going further

The action send in the store requests only keeps requests in the browser so far. Send them as JSON with POST to ${API}/reservations. On success, send returns { reservation, fields: {} }, and on a refusal { error, fields } with the message and the messages per field from the answer.

Solution
requests.js
// Sends a request as JSON and returns what the course API said about it
async function send(request) {
const response = await fetch(`${API}/reservations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})
const body = await response.json()
if (!response.ok) return { error: body.error, fields: body.fields ?? {} }
list.value.push(body)
return { reservation: body, fields: {} }
}

The page of a thing already shows result.error and the messages per field. In the browser, a member can sign in as M-117 or M-204, and the store auth handles the login at the course API.

Exercise Challenge

So far the overview "My requests" only shows what was requested in the browser. Load it in an action load(signal) from ${API}/reservations, show loading, error and an empty list, and abort the loading with an AbortController when someone leaves the page before it is done.

Solution
requests.js
// Loads the own requests. With the signal, the page cancels the request when it closes.
async function load(signal) {
status.value = 'loading'
try {
const response = await fetch(`${API}/reservations`, { signal })
const body = await response.json()
if (!response.ok) throw new Error(body.error)
list.value = body
status.value = 'ready'
} catch (problem) {
if (problem.name === 'AbortError') return
error.value = problem.message
status.value = 'error'
}
}
RequestsView.vue
// Whoever leaves the page before the answer arrives cancels the request
const controller = new AbortController()
onMounted(() => requests.load(controller.signal))
onUnmounted(() => controller.abort())

An abort is no error, so the action sets no status for it. Without a login, the API answers with 401, and the page shows its message.

MistakeWhat helps
A status like 500 shows up as a success.fetch only rejects without an answer. Check response.ok.
The course API answers with 415.The header Content-Type: application/json is missing, or the content isn't JSON.
The console reports that CORS blocked a request.The app calls the API at another origin. Use the proxy, or allow the origin on the server.
After leaving a page, a late answer still sets some state.Abort the request with an AbortController when the page goes away.
An AI assistant relies on the checks in the form alone.The server checks every request once more, and the app shows its messages.

Self-check

  1. When does fetch reject, and when does the action have to detect an error itself?
  2. What do the answers 201, 401, 409 and 422 of the course API mean?
  3. Why does the course API check the rules even though the form already does?
  4. Why doesn't the app need CORS with the Vite proxy?
Answers
  1. fetch only rejects when no answer arrives or the request gets aborted. The action checks every status itself with response.ok.
  2. 201 means created, 401 a missing login, 409 a conflict with days already requested and 422 fields that are wrong.
  3. Anyone can send requests without the app, and only the server knows about conflicts with other people's requests.
  4. For the browser, all requests go to the address of the app. Only Vite forwards them to the API, and the browser never sees another origin.

Assignment

The catalog, the requests and the returns all go through the course API. After a reload, though, the login is gone from the store, and a member who marks favorites in a second tab doesn't see them in the first one. Martina Hostettler also reports that half-filled requests get lost at the counter as soon as someone confirms the question on leaving to check the catalog quickly. The next chapter settles what the app keeps where in the browser.

Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.