Fetching From an API With Composables
Build a small fetch client and a generic composable for REST entities in Vue 3: lists with paging, search and sorting, single items, saving and deleting, with loading states, errors and no race conditions.

Quest objectives
- Wrap fetch in a small client with real error messages
- Write one composable that handles any entity of a REST API
- Page, search and sort a list, and reload it when any of them changes
- Let only the newest request count, by aborting the older ones
- Keep the differences between APIs in one place
Almost every app talks to an API, and almost every list in it needs the same things: a loading state, error messages, paging, a search, sorting, and buttons that save or delete. Written into every component, that is a lot of the same code. This tutorial puts it into one composable that works for any kind of entity, and a second, tiny one per entity.
The example API is DummyJSON, a free test API with 208 users. It pages, searches and sorts, and it answers to creating, changing and deleting as a real API would, without actually storing anything. The code builds on the project from Vue With Vite, Tailwind CSS and PrimeVue, but any Vue 3 project with TypeScript will do.
A client for the API
First, a thin layer over fetch that turns every request into JSON in, JSON out, and every failure into an error with a message:
// An error from the API, with the HTTP status and the API's own message
export class ApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
export type Api = ReturnType<typeof createApi>;
// A small wrapper around fetch for one JSON API
export function createApi(baseUrl: string) {
async function request<T>(
method: string,
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json' };
// Only with a body: on a GET, a Content-Type would make the browser
// ask the server for permission first (a CORS preflight)
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
// This is the place for an Authorization header, if your API needs one
const response = await fetch(baseUrl + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal,
});
if (!response.ok) {
throw new ApiError(response.status, await errorMessage(response));
}
// No Content, for example after a DELETE
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
return {
get: <T>(path: string, signal?: AbortSignal) => request<T>('GET', path, undefined, signal),
post: <T>(path: string, body: unknown) => request<T>('POST', path, body),
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
del: <T = void>(path: string) => request<T>('DELETE', path),
};
}
// The API's own message if it sends one, otherwise the HTTP status text
async function errorMessage(response: Response): Promise<string> {
try {
const body = await response.json();
if (typeof body?.message === 'string') {
return body.message;
}
} catch {
// Not JSON
}
return response.statusText || `HTTP ${response.status}`;
}
export const api = createApi('https://dummyjson.com/');fetchdoesn't fail on HTTP errors, only when there is no answer at all. The client checksresponse.okand throws anApiErrorwith the status and the API's own message, such as "User with id '9999' not found", so the page can show something better than "Error".The Content-Type header is only sent with a body. On a
GETit would make the browser send a preflight request first, one more round trip for nothing.A 204 No Content has no body to read, and
response.json()would fail on it.gettakes anAbortSignal, so a request can be cancelled. The composable will need that.
createApi is not a composable: it holds no reactive state, so one client can serve the whole app. That is why it doesn't start with use.
The direct way
The client alone is enough for a first list:
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { api } from '../api';
type User = { id: number; firstName: string; lastName: string; email: string };
const users = ref<User[]>([]);
onMounted(async () => {
const response = await api.get<{ users: User[] }>('users?limit=10');
users.value = response.users;
});
</script>
<template>
<ul>
<li v-for="user in users" :key="user.id">
{{ user.firstName }} {{ user.lastName }}, {{ user.email }}
</li>
</ul>
</template>It works, but it lacks a lot: nothing shows that it is loading, a failed request ends as an unhandled error in the console, there is only one page, and the next list repeats all of it. A composable takes care of that.
A composable for any entity
useEntity holds everything one kind of entity needs: a page of the list, one single item, saving, and the states that go with them. What differs from API to API, such as addresses and the shape of the list, comes in as config:
import { computed, ref, shallowReactive, shallowRef, type Ref } from 'vue';
import type { Api } from '../api';
// Which part of the list to load
export type ListQuery<T> = {
page: number;
limit: number;
search: string;
sortBy: keyof T | null;
order: 'asc' | 'desc';
};
// One page of the list, and how many items there are in total
export type ListPage<T> = { items: T[]; total: number };
// Where an entity lives in the API. APIs differ most in these.
export type EntityConfig<T, TList> = {
listUrl: (query: ListQuery<T>) => string;
itemUrl: (id: number) => string;
createUrl: string;
toPage: (response: TList) => ListPage<T>;
};
export function useEntity<T extends { id: number }, TList>(
api: Api,
config: EntityConfig<T, TList>,
) {
const query = shallowReactive<ListQuery<T>>({
page: 1,
limit: 10,
search: '',
sortBy: null,
order: 'asc',
});
const items = shallowRef<T[]>([]);
const total = ref(0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / query.limit)));
const loadingList = ref(false);
const item = shallowRef<T | null>(null);
const loadingItem = ref(false);
const saves = ref(0);
const saving = computed(() => saves.value > 0);
const error = shallowRef<Error | null>(null);
// Runs loads so that only the newest one counts: a new load aborts the one
// before, and only the newest one ends the loading state.
function latestOnly(loading: Ref<boolean>) {
let current: AbortController | null = null;
return async (load: (signal: AbortSignal) => Promise<void>) => {
current?.abort();
const controller = (current = new AbortController());
loading.value = true;
error.value = null;
try {
await load(controller.signal);
} catch (caught) {
if (!controller.signal.aborted) {
error.value = toError(caught);
}
} finally {
if (current === controller) {
loading.value = false;
}
}
};
}
const loadList = latestOnly(loadingList);
const loadItem = latestOnly(loadingItem);
function fetchList() {
return loadList(async (signal) => {
const response = await api.get<TList>(config.listUrl(query), signal);
signal.throwIfAborted();
const page = config.toPage(response);
items.value = page.items;
total.value = page.total;
});
}
function fetchItem(id: number) {
return loadItem(async (signal) => {
const response = await api.get<T>(config.itemUrl(id), signal);
signal.throwIfAborted();
item.value = response;
});
}
// Runs a create, update or delete. Returns its result, or null if it failed.
async function save<R>(task: () => Promise<R>): Promise<R | null> {
saves.value += 1;
error.value = null;
try {
return await task();
} catch (caught) {
error.value = toError(caught);
return null;
} finally {
saves.value -= 1;
}
}
function create(data: Omit<T, 'id'>) {
return save(async () => {
item.value = await api.post<T>(config.createUrl, data);
return item.value;
});
}
function update(id: number, data: Partial<T>) {
return save(async () => {
const updated = await api.put<T>(config.itemUrl(id), data);
item.value = updated;
items.value = items.value.map((entry) => (entry.id === id ? updated : entry));
return updated;
});
}
function remove(id: number) {
return save(async () => {
await api.del(config.itemUrl(id));
if (item.value?.id === id) {
item.value = null;
}
items.value = items.value.filter((entry) => entry.id !== id);
total.value -= 1;
return true;
});
}
return {
query,
items,
total,
pageCount,
loadingList,
fetchList,
item,
loadingItem,
fetchItem,
saving,
create,
update,
remove,
error,
};
}
function toError(caught: unknown): Error {
return caught instanceof Error ? caught : new Error(String(caught));
}Piece by piece:
querydescribes which part of the list to load: page, page size, search and sorting. It is ashallowReactiveobject, so a component can changequery.pagedirectly and watch the whole thing. Shallow is enough, because its values are plain strings and numbers, and it keepssortBytyped as a field ofT.itemsanditemareshallowRefs. The data from the API is replaced as a whole, never changed inside, so Vue doesn't need to make every user deeply reactive.latestOnlyfixes the classic race: the user clicks Next three times, three requests run, and the slowest one wins, whichever page it was for. Here a new load aborts the one before. An aborted request is not an error, and only the newest load ends the loading state.signal.throwIfAborted()catches the rare case of an answer that arrives just as a newer request starts.savewraps create, update and delete: it setssavinganderrorand returns the result, ornullif it failed, so the caller doesn't need atry.savingcounts running saves rather than being a plain boolean, so two at once don't end it early.After an update or a delete, the list shows the change right away, without loading again.
One composable per entity
For users, all that is left is to say where they are and what DummyJSON's lists look like:
import { api } from '../api';
import { useEntity } from './useEntity';
export type User = {
id: number;
firstName: string;
lastName: string;
email: string;
};
// What DummyJSON sends for a list of users
type UserList = { users: User[]; total: number };
export function useUsers() {
return useEntity<User, UserList>(api, {
listUrl: ({ page, limit, search, sortBy, order }) => {
const params = new URLSearchParams({
limit: String(limit),
skip: String((page - 1) * limit),
select: 'firstName,lastName,email',
});
if (sortBy) {
params.set('sortBy', sortBy);
params.set('order', order);
}
// DummyJSON searches at an address of its own
if (search) {
params.set('q', search);
return `users/search?${params}`;
}
return `users?${params}`;
},
itemUrl: (id) => `users/${id}`,
createUrl: 'users/add',
toPage: (response) => ({ items: response.users, total: response.total }),
});
}Paging becomes DummyJSON's limit and skip, select asks for only the fields the list shows, and searching uses its own address. Another API would only need another listUrl and toPage; useEntity stays the same. A useProducts or usePosts is just as short.
The list
The component now only describes the page:
<script setup lang="ts">
import { onWatcherCleanup, ref, watch } from 'vue';
import { useUsers, type User } from '../composables/useUsers';
const { query, items: users, total, pageCount, loadingList, fetchList, saving, remove, error } =
useUsers();
// Loads the list now, and again whenever the page, search or sorting changes
watch(query, fetchList, { immediate: true });
// Searches once typing pauses for 300 ms, starting on the first page
const search = ref('');
watch(search, (value) => {
const timer = setTimeout(() => {
query.search = value.trim();
query.page = 1;
}, 300);
onWatcherCleanup(() => clearTimeout(timer));
});
// A second click on the same column turns the order around
function sortBy(field: keyof User) {
query.order = query.sortBy === field && query.order === 'asc' ? 'desc' : 'asc';
query.sortBy = field;
}
</script>
<template>
<section>
<input v-model="search" type="search" placeholder="Search users" />
<p v-if="error" role="alert">{{ error.message }}</p>
<table :aria-busy="loadingList">
<thead>
<tr>
<th><button @click="sortBy('firstName')">First name</button></th>
<th><button @click="sortBy('lastName')">Last name</button></th>
<th><button @click="sortBy('email')">Email</button></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.firstName }}</td>
<td>{{ user.lastName }}</td>
<td>{{ user.email }}</td>
<td><button :disabled="saving" @click="remove(user.id)">Delete</button></td>
</tr>
</tbody>
</table>
<p v-if="loadingList">Loading...</p>
<p v-else-if="users.length === 0">No users found.</p>
<nav>
<button :disabled="query.page <= 1" @click="query.page--">Previous</button>
Page {{ query.page }} of {{ pageCount }}, {{ total }} users
<button :disabled="query.page >= pageCount" @click="query.page++">Next</button>
</nav>
</section>
</template>watch(query, fetchList, { immediate: true })loads the first page right away and loads again whenever the page, the search or the sorting changes. The buttons only changequery.The search field doesn't write to
queryon every key. It waits until typing pauses: every change starts a timer, andonWatcherCleanupcancels the one before. Searching starts again on page 1.Destructuring with new names, as in
items: users, gives the parts the names that fit the component. The refs stay reactive.
Click Next a few times quickly: the list ends on the right page, and no error appears. Search for "john", sort by first name twice, and delete a row: DummyJSON confirms the delete and the row disappears, although the user will be back when the page reloads.
Creating and the result of saving
create, update and remove return the result, or null if it failed:
const { create, error } = useUsers();
const user = await create({ firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' });
if (user) {
console.log('Created with id', user.id);
} else {
console.log('Failed:', error.value?.message);
}The files of this tutorial:
src/
├── api.ts
├── components/
│ └── UserList.vue
└── composables/
├── useEntity.ts
└── useUsers.tsTo point the client at another server per environment, use a Vite environment variable: createApi(import.meta.env.VITE_API_URL), with VITE_API_URL=https://api.example.com/ in .env. The base address ends with a slash, and paths start without one.