Jiří ProcházkaArticles

Jiří ProcházkaArticles

VueNuxt#frontend

Loading Data with Composables in Vue 3 - Part II.

2 weeks ago - 25 min read #vue, #frontend

In the previous article, I showed you a simple pattern for loading data with Vue 3 composables. It covered the basics of handling asynchronous requests, exposing state, and reusing logic across components.

In this follow-up, I’d like to extend that idea a bit further. This time, we’ll explore what happens when the source of your data isn’t a primitive value but objects and arrays. This brings a few tweaks worth discussing, and we’ll walk through them step by step.

In the last article, we had a helper watchParam that only worked with Ref<string | number | undefined>. That was fine as a starting point, but it quickly becomes limiting once you want to pass in other kinds of reactive values, or even plain values.

Here’s the improved version:

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

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

export function watchParam<T>(param: ParamRef | MaybeRef<T>, loadFunc: () => void) {
  watch(
    () => unref(param),
    (newValue) => {
      if (newValue) {
        loadFunc()
      }
    },
    { immediate: true },
  )
}

export type ParamRef = MaybeRef<string | number | undefined>

So what changed?

  • Generic type support – instead of being locked to just Ref<string | number | undefined>, we now accept any MaybeRef<T>. That means you can pass either a plain value or a Ref.
  • unref usage – this little helper unwraps a Ref if it’s reactive, or just returns the value if it’s not. This lets us simplify the code and support both cases seamlessly.
  • Cleaner type aliasParamRef still defines the “common” case (string or number, possibly undefined), but now it’s just a MaybeRef, which plays nicely with the generic function signature.

With this change, watchParam is much more flexible. It works with refs, computed values, and even plain props without requiring boilerplate wrapping.

Now that watchParam can handle more than just simple refs, let’s put it into practice.

First, here’s a composable for working with a single user:

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

export default function usePosts(user: Ref<User | undefined>) {
  const posts = ref<Post[]>([])
  const loading = ref(false)

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

  function clear() {
    posts.value = []
  }

  watchParam(user, load)

  return { loading, posts, clear }
}

This is almost the same pattern as before — we load posts for a single user, watch the user ref, and reload whenever it changes.

But what if instead of one user, we’re dealing with an array of users? Let’s try that:

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

export default function usePostsArray(users: Ref<User[]>) {
  const posts = ref<Post[]>([])
  const loading = ref(false)

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

  function clear() {
    posts.value = []
  }

  watchParam(users, load)

  return { loading, posts, clear }
}

Here the users ref is an array. We’re watching it the same way and reloading data whenever the array changes. In this example, I’m just pulling the id of the second user (users.value[1]) to keep it simple — but you can see the idea: arrays can be observed in exactly the same way.

To see it all in action, let’s wire everything together in a simple component:

<script setup lang="ts">
import { computed, ref } from 'vue'

import useTodos from './composables/todos'
import useUsers from './composables/users'
import usePosts from './composables/posts'
import usePostsArray from './composables/postsArray'

const userId = ref<number>()
const firstUser = computed(() => users.value[0])

const { loading: usersLoading, load: loadUsers, users, clear: clearUsers } = useUsers()
const { loading: todosLoading, todos, clear: clearTodos } = useTodos(userId)
const { loading: postsLoading, posts, clear: clearPosts } = usePosts(firstUser)
const { loading: postsArrayLoading, posts: postsArray, clear: clearPostsArray } = usePostsArray(users)

function clear() {
  clearUsers()
  clearTodos()
  clearPosts()
  clearPostsArray()
}
</script>

<template>
  <button type="button" @click="userId = users[0]?.id">First</button>
  <button type="button" @click="userId = users[1]?.id">Second</button>
  <button type="button" @click="clear">Clear</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>Posts</h1>
  <ul v-if="!postsLoading">
    <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
  </ul>
  <p v-else>Loading posts...</p>
  <h1>Posts Array</h1>
  <ul v-if="!postsArrayLoading">
    <li v-for="post in postsArray" :key="post.id">{{ post.title }}</li>
  </ul>
  <p v-else>Loading posts...</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>

This small demo lets you load users, switch between them, and immediately see how related data (todos and posts) reacts to the changes. Thanks to watchParam, both single values and arrays are handled consistently — the components stay simple, and the logic stays reusable.

Conclusion

By generalizing our watcher to use MaybeRef and unref, we unlocked a lot more flexibility. We can now watch not only plain refs, but also computed values and even arrays of objects without changing the composable pattern. This keeps the data flow predictable and the codebase clean — and it sets us up nicely for more advanced use cases.

Jiří Procházka Newsletter

Want to get new tips for Vue & Nuxt?