Skip to content

Reusable Queries

While useQuery() can be directly called in components, we often need to reuse the same query across components or even add extra properties like consuming the route via useRoute() or passing a search text to the API request. In those scenarios, it's more convenient to define queries and reuse them where needed.

But why is this necessary? Can't we just create a regular composable for this?

ts
// src/queries/todos.ts
import { 
useQuery
} from '@pinia/colada'
import {
ref
} from 'vue'
export function
useFilteredTodos
() {
const
search
=
ref
('')
const
query
=
useQuery
({
key
: () => ['todos',
search
.
value
],
query
: () =>
fetch
(`/api/todos?search=${
search
.
value
}`).
then
((
res
) =>
res
.
json
()),
}) return {
search
, ...
query
}
}

Not exactly because we are mixing component state (search) with global state (useQuery() creates global state). There are two main issues with this approach:

  • The ref search isn't shared among components. Each component instance creates a new ref for themselves
  • If the query is reused across different components, there will be a de-synchronization between the search ref instantiated in each component and the one used in the query's key. Since the query is global, it's only instantiated once, and only the first search is used. Therefore, if we use this approach, only the changes to the search of the component that first instantiated the query will take effect.

Because of this, Pinia Colada provides an alternative way of defining a query, through the defineQuery composable. Simply wrap your composable with it:

ts
import { 
defineQuery
,
useQuery
} from '@pinia/colada'
import {
ref
} from 'vue'
export const
useFilteredTodos
=
defineQuery
(() => {
// `search` is shared by all components using this query const
search
=
ref
('')
const {
state
, ...
rest
} =
useQuery
({
key
: () => ['todos', {
search
:
search
.
value
}],
query
: () =>
fetch
(`/api/todos?filter=${
search
.
value
}`, {
method
: 'GET' }),
}) return { ...
rest
,
// we can rename properties for convenience too
todoList
:
state
,
search
,
} })
vue
<script setup lang="ts">
import { useFilteredTodos } from '@/queries/todos'

const { todoList, search } = useFilteredTodos()
</script>

Consider useFilteredTodos() as a globally shared composable, instantiated only once. This ensures the search ref is shared across all components using this query, reflecting changes universally across components.

TODO: talk about defineQueryOptions instead

When you just want to organize your queries, you can also pass an object of options to defineQuery():

ts
// src/queries/todos.ts
import { 
defineQuery
} from '@pinia/colada'
export const
useTodos
=
defineQuery
({
key
: ['todos'],
query
: () =>
fetch
('/api/todos').
then
((
res
) =>
res
.
json
()),
})

When to use defineQuery() over just useQuery()?

If you find yourself with the same query (same key value) used in multiple components that are also mounted at the same time, you must use defineQuery(). This will ensure that the query is shared among all components.

If you need to reuse a query in multiple components, move the query to a separate file (e.g. src/queries/todos.ts) and use defineQuery() to define it. This will ensure that the query code isn't partially updated in your code base.

If you need to define custom parameters that aren't global, you don't need to do anything special, create a custom composable instead and call useQuery() within it.

Nuxt

When using defineQuery() in Nuxt, useRoute() returns a different version of the route, it is recommended to explicitly import it from vue-router instead of using the Nuxt version (automatically imported):

ts
import { useRoute } from 'vue-router'

export const useContactDetails = defineQuery(() => {
  const route = useRoute()
  return useQuery({
    key: () => ['contacts', route.params.contactId],
    query: () => fetch(`/api/contacts/${route.params.contactId}`).then((res) => res.json()),
  })
})

If you don't do this, you will see the query being triggered more than it should, specifically when navigating to or away from the page. It might be even undefined. This is due to how Nuxt internally handles the integration with Suspense.

Caveat: SSR and defineQuery()

While defineQuery() looks like a setup store in pinia, it doesn't define a store, the state returned is not serialized to the page. This means that you are fully responsible for ensuring consistent values across the server and client for anything that is not returned by useQuery(). In short, this means that you cannot have code like this:

ts
defineQuery(() => {
  const search = ref('')
  // ❌ different values on client and server
  if (import.meta.env.SSR) {
    search.value = fetchSomeInitialValue()
  }
  const query = useQuery({
    key: ['todos', search.value],
    query: () => fetch(`/api/todos?search=${search.value}`).then((res) => res.json()),
  })
  return {
    search,
    ...query,
  }
})

Instead, you will need to ensure the search state is serialized from the server to the client. The simplest way is to move it to a store:

ts
import { 
defineStore
,
storeToRefs
} from 'pinia'
const
useLocalStore
=
defineStore
('query-search-store', () => {
const
search
=
ref
('')
return {
search
}
})
defineQuery
(() => {
const {
search
} =
storeToRefs
(
useLocalStore
())
if (import.meta.
env
.
SSR
) {
search
.
value
=
getInitialValue
()
} const
query
=
useQuery
({
key
: ['todos',
search
.
value
],
query
: () =>
fetch
(`/api/todos?search=${
search
.
value
}`).
then
((
res
) =>
res
.
json
()),
}) return {
search
,
...
query
,
} })

TIP

If you are using Nuxt, you can simply replace the ref() with Nuxt's useState() instead of creating a store.

Released under the MIT License.