Skip to content
DasPeTe Tutorials

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.

Intermediate40 min

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:

src/api.ts
// 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/');
  • fetch doesn't fail on HTTP errors, only when there is no answer at all. The client checks response.ok and throws an ApiError with 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 GET it 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.

  • get takes an AbortSignal, 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:

src/components/FirstUserList.vue
<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:

src/composables/useEntity.ts
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:

  • query describes which part of the list to load: page, page size, search and sorting. It is a shallowReactive object, so a component can change query.page directly and watch the whole thing. Shallow is enough, because its values are plain strings and numbers, and it keeps sortBy typed as a field of T.

  • items and item are shallowRefs. The data from the API is replaced as a whole, never changed inside, so Vue doesn't need to make every user deeply reactive.

  • latestOnly fixes 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.

  • save wraps create, update and delete: it sets saving and error and returns the result, or null if it failed, so the caller doesn't need a try. saving counts 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:

src/composables/useUsers.ts
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:

src/components/UserList.vue
<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 change query.

  • The search field doesn't write to query on every key. It waits until typing pauses: every change starts a timer, and onWatcherCleanup cancels 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:

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

text
src/
├── api.ts
├── components/
│   └── UserList.vue
└── composables/
    ├── useEntity.ts
    └── useUsers.ts

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

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.