Writing a composable function to fetch data from REST API in Vue.js
Tags: vue, typescript
Published: 2021-04-28 22:06:57

Composition API is best thing that happened to Vue. It makes it possible to write reusable parts of components (logic) in clear and TypeScript friendly way. New reactivity system introduced with Composition API (both Vue 3 and plugin for Vue 2) allows to write pretty clear and universal code.

Let's start with defining what we need to make request. There is always an endpoint and maybe some parameters: filters, pagination, ordering.... Whatever, it's not our problem here, we'll deal with it later.

## Simple URL listener

At first, we'll write simple URL listener that will reload the data, when it's been update

```tsx
const useUrlQuery = <T>(url: string | Ref<string>) => {
  const result = ref<T | null>(null);
  const reload = async () => {
    const res = await fetch(unref(url));
    const data = await res.json();
    result.value = data;
  };
  if (isRef(url)) { // if url is reactive
    watch(url, reload);
  }
  reload();
  return {
    result,
    reload,
  };
};
```

And we are done. We can use it as simple as that:

```tsx
setup () {
  const page = ref(0);
  const url = computed(() => `/items?page=${page.value}`);
  const { result: list } = useUrlQuery<ItemType[]>(url);
  return {
    list,
    page
  };
}
```

We provide `url` that is either just string or reactive that contains string. `computed` is reactive and compatible with `Ref<T>`, it's value is read-only, if `set` is not implemented, so we don't have to type separately for `ref` and `computed`. If we type something as `T | Ref<T>`, it means it might, but doesn't have to be reactive and when we want to use it's value, we should use `unref(val)` instead of trying to get into `.value` property (if it's used inside computed or watch, it will count it as dependency if it is reactive). Later we check if `url` is reactive and mount a watcher if it is. Also thanks to that we are watching a primitive at the end, it means it won't run if it won't change.

Notice that we rename `result` into `list` here. We use JavaScript's destructuring with renaming fields. That way we can avoid any name conflict, which is might happen with mixins.

Whenever `page` will change, `url` will be recomputed and fire `watch(url, reload)`. At initial state, `result` will be `null`, so we might want to add some default value, to avoid the type `T | null`. Also we'd like to know, if there is a request ongoing. We'll add `isLoading` state and `error`:

```tsx
const useUrlQuery = <T, K = T | null>(
  url: string | Ref<string>,
  initial: K
) => {
  const result = ref<K>(initial);
  const state = reactive({
    isLoading: false,
    error: null as any,
  });

  const reload = async () => {
    state.isLoading = true;
    try {
      const res = await fetch(unref(url));
      const data = await res.json();
      result.value = data;
    } catch (e) {
      state.error = e;
    } finally {
      state.isLoading = false;
    }
  };

  if (isRef(url)) {
    watch(url, reload);
  }

  return {
    ...toRefs(state),
    result,
    reload,
  };
};
```

In case `K` to be `T`, TypeScript won't consider there other type than the one provided, so you won't have to write the type manually (you can also assume `null` to be default), if it's endpoint for list, you can easily pass empty array and cast it into array type (`[] as ItemType[]`), or put a placeholder object.

Now we can use `isLoading` to notify users that the list they are seeing is not yet updated. You can add a spinner, hide list, whatever you designed for that case.

```tsx
setup () {
  const { result: list, isLoading, error } = useUrlQuery('/items', [] as ItemType[]);
  return {
    list,
    isLoading,
    error,
  }
}
```

### HINT: page number from URL

```tsx
const route = useRoute();
const url = computed(() => `/items/?page=${route.params.page}`);
```

## Guard

But sometimes requests are longer, and sometimes happens to a situation that we receive response from newer query before older one finishes. That is a case when for example we remove some filters, or we just fast between pages and backend will need some more time to get one. For example: we are at page 1. We click "page 2". It starts loading, but before it finishes, we jump to third. Third page has only one element, so backend responds before it finishes the request for second page. Composable sets data for third page, and them BUM! Response for second page comes and fires the `reload` that was fired much earlier and replaces result to that for invalid page.

We have few approaches here. If we are using native `fetch`, we might use `AbortController`. It's simple change:

```tsx
  let controller: AbortController | null = null;
  const reload = async () => {
    controller?.abort();
    state.isLoading = true;
    try {
      controller = new AbortController();
      const res = await fetch(unref(url), { signal: controller.signal });
      result.value = await res.json();
      state.isLoading = false;
    } catch (e: any) {
			if(e.name === "AbortError") {
        // request was aborted
        return;
      }
      // will set error and disable `isLoading` only if it wasn't aborted
      state.isLoading = false; 
      state.error = e;
    }
  };
```

And that's it! But that is a case when we use native Fetch. We also had to remove `finally` section, because there is a case (abort) in which we don't set `isLoading` to `false` If you use Axios, you'd need it's own approach. We might also let developer provide their own async function that will return the state, in that case either we'll have to force to provide a function to abort request, or protect it from storing invalid result.

```tsx
try {
  const forUrl = url.value;
  // fetch
  const data = await res.json();
  ****if (url.value == forUrl) {
    result.value = data; //set only if url is still the same
  }
**} //...**
```

With that solution we can listen to actual URL and accept result only if it didn't changed. This also will accept former response in case we switched from page 1 to 2, then to 3 and back to 2. If the first request for page 2 will finish when page 2 was really selected, it will accept this response in that case (also will accept last one, but nothing will change, if no change happened in database).

This also could lead to further optimizations, like caching last few results which we could implement easily.

## URL builder

Now that we have a composable that will listen to url changes and update it's local state, we should work. Building URL manually might be chaotic, that's what `URLSearchParams` is for. We'll implement a builder that will build query parameters from object:

```tsx
type ParamType = string | number | boolean;
type ParamsType = Record<string, ParamType>;
export const useParamBuilder = (params: ParamsType | Ref<ParamsType>) => {
  return computed(() => {
		const search = new URLSearchParams();
    const obj = unwrap(params); // unwrap in case if it's Ref
    const entries = Object.entries(obj)
      .filter(e => e[1] != null)
      .filter(e => typeof e[1] == "string" ? e[1].length > 0 : true)
      .sort(([a], [b]) => a > b ? 1 : a < b ? -1 : 0);
    entries.forEach(([key, value]) => search.append(key, value.toString()));
    return search
  });
}

export const useUrlParams = (base: string, params: Readonly<Ref<URLSearchParams>>) => {
  return computed(() => `${base}?${params.value.toString()}`);
}
```

Implementation doesn't matter here that much, here we just put strings, numbers and booleans into query. At first we filter out nulls and empty strings, then we sort entries by key alphabetically, to have them in the same order (it is important to have `watch` noticing the difference correctly).

```tsx
const filter = ref<ItemFilters>({
  active: false,
  search: "", //will be filtered out if empty,
  category_id: null, //filtered out if null
});

const { result: list } = useUrlQuery(
  useUrlParams('/items', useParamBuilder(fitler)),
  []
);

return {
  list
}
```

Of course you can merge those two composables into one, if you won't use them separately. 

```tsx
export const useUrlParams = (base: string, params: Ref<Record<string, string|number|boolean>) => {
  return computed(() => {
    const search = new URLSearchParams();
    const obj = params.value;
    const entries = Object.entries(obj)
      .filter(e => e[1] != null)
      .filter(e => typeof   e[1] == "string" ? e[1].length > 0 : true)
      .sort(([a], [b]) => a > b ? 1 : a < b ? -1 : 0);
    entries.forEach(([key, value]) => search.append(key, value.toString()));
    return `${base}?${params.value.toString()}`
  });
}
// in setup:
const { result: list } = useUrlQuery(
  useUrlParams('/items', fitler),
  []
);
```

That way we packed functionality of almost every fetching data from REST API in single one composable function. 

## Example ToDo list component

```tsx
<template>
  <div>
    <h1>TODOS:</h1>
    <input :value="filters.search" @input="setSearch" placeholder="search" />
    <ul class="todo-list" :class="{ 'is-loading': isLoading }">
      <li class="todo-item" v-for="item in list" :key="item.id">
        <input type="checkbox" :value="item.done" @change="setDone(item)" />
        {{ item.text }}
      </li>
    </ul>
		<button @click="reload">Reload</button>
  </div>
</template>
<script lang="ts">
import { defineComponent, reactive, computed } from "vue";
import { useUrlParams, useUrlQuery } from "@/composables/useApiRequest";
import debounce from "lodash/debounce";

type TodoItem = {
  id: number;
  text: string;
  done: boolean;
};

const defaultPlaceholder = [
  { id: 1, text: "Perform fetch", done: false },
] as TodoItem[]

export default defineComponent({
  name: "TodoListComponent",
  setup() {
    const state = reactive({
      loading: false,
    });
    const filters = reactive({
      done: false,
      search: "",
    });
    const url = useUrlParams("/todos", filters);
    const { result: list, reload, isLoading: listLoading } = useUrlQuery(url, defaultPlaceholder);

    const setDone = async (item: TodoItem) => {
      state.loading = true;
      try {
        const response = await fetch(`/todos/${item.id}`, {
          method: "PATCH",
          body: JSON.stringify({ done: !item.done }),
        });
        await response.json(); // ignore response
        await reload();
      } catch (e) {
        console.error(e);
      } finally {
        state.loading = false;
      }
    };

    const setSearch = debounce((event: InputEvent) => {
      filters.search = (event.target as HTMLInputElement).value;
    }, 200); // prevent reloading after each character

    return {
      isLoading: computed(() => listLoading.value || state.loading),
      filters,
      list,
      reload,
      setDone,
      setSearch,
    };
  },
});
</script>

<style scoped>
.is-loading {
  opacity: 0.5;
  pointer-events: none;
}
.todo-list {
  margin: 0;
  padding: 0;
}
.todo-item {
  cursor: pointer;
}
</style>
```

Code doesn't focus on layout, just example usage of written composable. This article focused only on GET request that listens to URL.

## Conclusion

With new Vue's Composition API we are able to delegate logic outside the component and reuse it without having to care about names conflicts, like it happen with mixins, it's also much easier to parametrize composables than mixins. It's easy, safe and speeds up the development. This was just an example, another time I'll provide some more examples of usage of this new API.