Jiří ProcházkaArticles

Jiří ProcházkaArticles

VueNuxt#frontend

Loading Data with Composables in Vue 3

3 weeks ago - 20 min read #vue, #frontend

In Vue 3, you’ll often need to fetch data — and the cleanest way to do it is with composables. Instead of mixing API calls into your components, you move them into small, focused functions that return reactive data, loading, and error states.

This keeps components simple, makes your logic reusable, and gives you a consistent pattern for handling data across the app. It’s a lightweight, pure Vue approach that works great even as your project grows.

Moreover, the composable hooks into the onMounted lifecycle, so the data loads automatically as soon as the component is rendered.

The Code Itself

Let’s start with a tiny utility file:

// ~/utils/api.ts
import axios from 'axios'
import { watch, type Ref } from 'vue'

const instance = axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com',
})
export default instance

export function watchParam(param: Ref<string | number | undefined>, loadFunc: () => void) {
  watch(
    () => param.value,
    () => {
      if (param.value) {
        loadFunc()
      }
    },
    { immediate: true },
  )
}

It creates a reusable Axios instance with a predefined baseURL. This means every request in the app can just use instance.get('/todos') instead of repeating the full URL each time.

It defines a small helper function called watchParam. This function watches a reactive parameter (like a ref or prop) and automatically calls a given loading function whenever the parameter changes. Thanks to { immediate: true }, it also runs right away on mount.

This makes it super handy for cases like:

  • loading a user profile whenever the userId changes
  • refreshing a list based on a selected filter,
  • or simply auto-fetching data when the component first appears.

So right from the start, we have a centralized API client and a smart helper for reactive data loading.

Users composable

The useUsers composable is very straightforward: it loads all users once the component is mounted. It exposes two reactive values — users and loading — so the component can easily react to the state of the request.

// ~/composables/users.ts
import type { User } from '@/types/User'
import { onMounted, ref } from 'vue'
import api from '@/utils/api'

export default function useUsers() {
  const users = ref<User[]>([])
  const loading = ref(false)

  onMounted(async () => {
    loading.value = true
    const res = await api.get('/users')
    users.value = res.data
    loading.value = false
  })

  return { loading, users }
}

Todos composable

The useTodos composable is a bit smarter. Instead of always fetching all todos, it depends on a userId parameter. Thanks to the watchParam helper we defined earlier, it automatically reloads todos whenever the userId changes — and also runs immediately on mount.

This makes it perfect for cases where you want to show todos for the currently selected user, without having to manually re-trigger the loading each time.

// ~/composables/todos.ts
import { ref, type Ref } from 'vue'
import api, { watchParam } from '@/utils/api'
import type { ToDo } from '@/types/ToDo'

export default function useTodos(userId: Ref<number | undefined>) {
  const todos = ref<ToDo[]>([])
  const loading = ref(false)

  async function load() {
    loading.value = true
    const res = await api.get(`/todos?userId=${userId.value}`)
    todos.value = res.data
    loading.value = false
  }

  watchParam(userId, load)

  return { loading, todos }
}

Putting it all together in App.vue

Finally, let’s see how easy the usage becomes in a component.

In App.vue, we just import the composables, grab the reactive values they expose, and bind them to the template:

// ~/App.vue
<script setup lang="ts">
import { ref } from 'vue'

import useTodos from './composables/todos'
import useUsers from './composables/users'

const userId = ref<number>()
const { loading: usersLoading, users } = useUsers()
const { loading: todosLoading, todos } = useTodos(userId)
</script>
<template>
  <button type="button" @click="userId = users[0]?.id">First</button>
  <button type="button" @click="userId = users[1]?.id">Second</button>

  <h1>Users</h1>
  <ul v-if="!usersLoading">
    <li v-for="user in users" :key="user.id">{{ user.username }}</li>
  </ul>
  <p v-else>Loading users..</p>

  <h1>Todos</h1>
  <ul v-if="!todosLoading">
    <li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>
  </ul>
  <p v-else>Loading todos...</p>
</template>

Notice how the component itself doesn’t contain any data-fetching logic — no fetch calls, no error handling, no lifecycle hooks. Thanks to the composables, all of that is abstracted away.

The result is a component that reads almost like plain HTML:

  • buttons change the userId
  • the todos composable reacts automatically
  • and the template just shows loading states or the data

That’s the real power of this pattern: simplicity in the component, reusability in the logic.

Jiří Procházka Newsletter

Want to get new tips for Vue & Nuxt?