Queries
Queries manage asynchronous state declaratively, allowing you to focus on the state, its status, and any potential errors. They automatically deduplicate multiple requests and cache results, enhancing user experience with faster performance.
Queries are designed to read data from asynchronous sources, such as handling GET
requests in a REST API but they can be used along any function returning a Promise. For writing or mutating data, consider using mutations, which are better suited for those operations.
Queries can be created with useQuery()
or defineQuery()
.
Basic Usage
The most basic usage of Pinia Colada's queries is through useQuery()
. This composable should feel familiar if you've used libraries like swrv or TanStack Query and should be the starting point for most of your async state management. If you want to reuse queries or better organize your code, use the defineQuery()
below.
<script setup lang="ts">
import { useQuery } from '@pinia/colada'
const {
// main query properties
state,
asyncStatus,
refresh,
refetch,
// convenient aliases
error,
data,
status,
isLoading,
isPending,
isPlaceholderData,
} = useQuery({
key: ['todos'],
query: () => fetch('/api/todos').then((res) => res.json()),
})
</script>
<template>
<main>
<div v-if="asyncStatus === 'loading'">
Loading...
</div>
<div v-else-if="state.status === 'error'">
Oops, an error happened...
</div>
<div v-else-if="state.data">
<TodoItem v-for="todo in state.data" :key="todo.id" :todo="todo" />
</div>
</main>
</template>
All queries require two properties:
- A unique
key
that defines the query in the cache - A
query
function with no arguments that retrieves (e.g. fetches) the data
useQuery
accepts other options to configure its behavior. You can find more about the options in the docs or explore them by using auto-completion in your editor!
What's great about queries is that they are automatically triggered when needed, enabling a declarative approach that just works!. That's why they don't take any parameters.
Most of the time you will find yourself using just state
and asyncStatus
to render the UI based on the query's status (e.g. is it still fetching, is it refreshing, did it throw?, ...). Let's cover the basics of these properties:
state
: state of the query. It contains the following properties:data
: the data returned by the query. It automatically updates when the query is refetched.error
: the error returned by the query. It'snull
if the query was successful.status
: the data status of the query. It starts as'pending'
, and then it changes to'success'
or'error'
depending on the outcome of thequery
function:status data error 'pending'
undefined
null
'success'
defined null
'error'
undefined
or defineddefined
asyncStatus
: the async status of the query. It's either'idle'
or'loading'
if the query is currently being fetched.state.status
/status
vsasyncStatus
?state.status
(andstatus
) is the status of the data itself, whileasyncStatus
is the status of the query call. The queryasyncStatus
is'idle'
when the query is not fetching and'loading'
when the query is fetching. While thestate.status
starts as'pending'
and then becomes'success'
if the query is successful, and'error'
if the query fails.Technically, these two states could be combined into a single one but having them separate allows you to have more control over the UI and the logic of your application. For example, you might want to show a different loading message if the query is pending (it hasn't ever resolved or rejected) or if it's loading (it's currently fetching data independently from the current
state
).refresh()
: manually triggers the query deduplicates requests and reuses the cached data if it's still fresh.refetch()
: manually triggers the query, ignoring the cache and fetching the data again.data
,error
,status
: are aliases for the properties instate
for convenience and facilitating migration.state
allows for type narrowing in TypeScript but depending on your template usage, you might not need it so we simply provide both approaches for convenience.For everything else, hover over the different properties in the code block above to see their types and documentation 😁.
You can access the fetched data through the references returned by useQuery()
(data
), as well as other query state, such as its status
, error
and more. It also returns methods to manually trigger the query like refresh()
and refetch()
.
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?
// 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 firstsearch
is used. Therefore, if we use this approach, only the changes to thesearch
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:
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,
}
})
<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.
When you just want to organize your queries, you can also pass an object of options to defineQuery()
:
// 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 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.
Using External Properties in Queries
Since queries are automatically triggered by Pinia Colada, the query
function cannot accept parameters. However, you can directly use external properties like route params or search queries within the query
function. To ensure proper caching, add these properties to the key
as a function. A common example is using the route
within the query
function:
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useQuery } from '@pinia/colada'
const route = useRoute()
const { state } = useQuery({
key: () => ['contacts', route.params.id as string],
query: () =>
fetch(`/api/contacts/${route.params.id}`).then((res) => res.json()),
})
</script>
Each unique key
generates a new query entry in the cache. When you switch back to a previously cached entry, it reuses the cached data, avoiding unnecessary network requests. This enhances your application's performance and responsiveness, making navigations feel instant ✨.
TypeScript: Narrowing data
and error
's type with status
When using status
to conditionally render different UI states, you can use the state
property returned by useQuery()
which groups the status
, data
, and error
properties into a single object. This way, TypeScript can narrow down the type of data
and error
correctly:
<script setup lang="ts">
import { useQuery } from '@pinia/colada'
const {
data,
status,
error,
state,
} = useQuery({
// ...
})
</script>
<template>
<div v-if="state.status === 'pending'">
Loading...
</div>
<div v-else-if="state.status === 'error'">
<!-- ✅ error type excludes `null` -->
Error fetching user info: {{ state.error.message }}
</div>
<!-- no need to check for the last possible status: 'success' -->
<div v-else>
<!-- ✅ data type excludes `undefined` -->
{{ state.data.name }}
</div>
</template>
Why is this necessary?
This approach is necessary because in Vue we have to use Ref
wrappers for variables, and TypeScript is not able to narrow down the type like it does with a plain object. When using status
to conditionally render different UI states, you will notice that TypeScript is unable to narrow down the type of data
not being undefined
when status
is 'pending'
or 'error'
.
<script setup lang="ts">
import { useQuery } from '@pinia/colada'
const {
data,
status,
error,
} = useQuery({
// ...
})
</script>
<template>
<div v-if="status === 'pending'">
Loading...
</div>
<div v-else-if="status === 'error'">
Error fetching user info: {{ error.message }} </div>
<!-- no need to check for the last possible status: 'success' -->
<div v-else>
{{ data.name }} </div>
</template>
While using a v-if="data"
or data!
both work and are perfectly fine, using the state
property provides a more elegant and type-safe solution.
<script setup lang="ts">
// ...
</script>
<template>
<div v-if="status === 'pending'">
Loading...
</div>
<div v-else-if="status === 'error'">
<!-- Not type safe but valid at runtime -->
Error fetching user info: {{ error!.message }}
</div>
<!-- Unnecessary check for `undefined` -->
<div v-else-if="data">
{{ data.name }}
</div>
</template>
Refetching Queries
You can manually trigger queries using the refetch()
and refresh()
methods. Both return a promise, with errors caught to prevent Uncaught Promise Rejection errors when used directly in the template. They both return the state
object, which contains the status
, data
, and error
properties.
const {
// ...
refresh,
refetch,
} = useQuery({
// ...
})
refetch().then(({ data, error }) => {
if (error) {
console.error('Last Error:', error)
} else {
console.log('Fetched data:', data)
}
})
// Pass `true` to throw if the query fails
refetch(true).catch((error) => {
console.error('Error refetching:', error)
})
When to use refetch()
and refresh()
In practice, aim to use refresh()
as much as possible because it will reuse any loading request and avoid unnecessary network calls based on staleTime
.
Use refetch()
when you are certain you need to refetch the data, regardless of the current status. This is useful when you want to force a new request, such as when the user explicitly requests a refresh.
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:
defineQuery(() => {
const search = ref('')
// ❌ different values on client and server
if (import.meta.env.SSR) {
search.value = fetchSomeInitialValue()
}
return {
search,
query: () =>
fetch(`/api/todos?search=${search.value}`).then((res) => res.json()),
}
})
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:
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()
}
return {
search,
query: () =>
fetch(`/api/todos?search=${search.value}`).then((res) => res.json()),
}
})
TIP
If you are using Nuxt, you can simply replace the ref()
with Nuxt's useState()
instead of creating a store.