DEV Community

jkonieczny
jkonieczny

Posted on

Writing a composable function to fetch data from REST API in Vue.js

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

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

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

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

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:

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

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.

setup () {
  const { result: list, isLoading, error } = useUrlQuery('/items', [] as ItemType[]);
  return {
    list,
    isLoading,
    error,
  }
}
Enter fullscreen mode Exit fullscreen mode

HINT: page number from URL

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

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:

  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;
    }
  };
Enter fullscreen mode Exit fullscreen mode

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.

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
  }
**} //...**
Enter fullscreen mode Exit fullscreen mode

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:

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()}`);
}
Enter fullscreen mode Exit fullscreen mode

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

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
}
Enter fullscreen mode Exit fullscreen mode

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

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),
  []
);
Enter fullscreen mode Exit fullscreen mode

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

Example ToDo list component

<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>
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)