Loading Data with Composables in Vue 3
3 weeks ago - 20 min read • #vue, #frontendIn 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.