🎁 Upgrade to Feathers-Pinia 2.0 🎁
Feathers-Pinia 2.0 is almost ready for final release. Read the new documentation.The useFindWatched
Utility ⚠️
⚠️ warning ⚠️
This API has been replaced by the new useFind
API. To make the migration process easier, the old useFind
is now provided as useFindWatched
. This API will likely be removed in the future.
The useFindWatched
utility reduces boilerplate for querying with fall-through cache and realtime updates. To get started with it you provide a model
class and a computed params
object.
Let's use the example of creating a User Guide, where we need to pull in the various Tutorial
records from our tutorials
service. We'll keep it simple in the template and just show a list of the names.
The goal with the examples is to focus as much as possible on functionality and not boilerplate. As such, all examples use auto-import for Vue APIs like computed
and ref
. They also use Vue's script setup
feature. Both features come preinstalled with the Vitesse Template for Vue and the Vitesse-Feathers-Pinia Demo.
<template>
<ul>
<li v-for="tutorial in tutorials" :key="tutorial.id">
{{ tutorial.name }}
</li>
</ul>
</template>
<script setup>
import { useFindWatched } from 'feathers-pinia'
import { useTutorials } from '../store/tutorials'
// 1. Register and use the store
const tutorialStore = useTutorials()
// 2. Create a computed property for the params
const tutorialsParams = computed(() => {
return {
query: {},
}
})
// 3. Provide the Model class and params in the options
const tutorialsData = useFindWatched({ model: tutorialStore.Model, params: tutorialsParams })
const tutorials = tutorialsData.items;
</script>
Let's review each of the numbered comments, above:
- Register and use the store. Since Pinia uses independent stores, the best practice is to import and use them wherever needed. Once you've called the equivalent to
useTutorials
, theModel
property can be pulled from the store. - Create a computed property for the params. Return an object with a nested
query
object. - Provide the Model class and params in the options
Options
Here's a look at the TypeScript definition for the UseFindWatched
interface.
interface UseFindWatched {
model: Function
params: Params | Ref<Params>
fetchParams?: Params | Ref<Params>
queryWhen?: ComputedRef<boolean> | QueryWhenFunction
qid?: string
immediate?: boolean
}
And here's a look at each individual property:
model
must be a Feathers-Pinia Model class. The Model'sfind
andfindInStore
methods are used to query data.params
is a FeathersJS Params object OR a Composition APIref
(orcomputed
, since they return aref
instance) which returns a Params object.- When provided alone (without the optional
fetchParams
), this same query is used for both the local data store and the API requests. - Explicitly returning
null
will prevent an API request from being made. - You can use
params.qid
to dynamically specify the query identifier for any API request. Theqid
is used for tracking pagination data and enabling the fall-through cache across multiple queries. - Set
params.paginate
totrue
to use server-side pagination. Realtime updates will continue to come into the store. UI results will only update when another response is sent from the server for the same query. - Set
params.debounce
to an integer and the API requests will automatically be debounced by that many milliseconds. For example, settingdebounce: 1000
will assure that the API request will be made at most every 1 second. - Set
params.temps
totrue
to include temporary (local-only) items in the results. Temporary records are instances in the store without a server-assigned id. They have not been saved to the database, yet. - Set
params.copies
totrue
to include cloned items in the results. The queried items get replaced with the corresponding copies fromcopiesById
- When provided alone (without the optional
fetchParams
This is a separate set of params that, when provided, will become the params sent to the API server. Theparams
will then only be used to query data from the local data store.- Explicitly returning
null
will prevent an API request from being made (but only for Vue 3. For Vue 2, usequeryWhen
).
- Explicitly returning
queryWhen
provides a logical separation for preventing API requests outside of theparams
. It must be acomputed
property that returns one of the following:- a
boolean
- a
QueryWhenFunction
, receiving aQueryWhenContext
and returning a boolean.
- a
qid
allows you to specify a query identifier (used in the pagination data in the store). This can also be set dynamically by returning aqid
in the params.immediate
, which istrue
by default, determines if the internalwatch
should fire immediately. Setimmediate: false
and the query will not fire immediately. It will only fire on subsequent changes to the params.
Returned Attributes
Notice the tutorialsData
in the previous example. You can see that there's an tutorialsData.items
property, which is returned at the bottom of the setup
method as tutorials
. There are many more attributes available in the object returned from useFindWatched
. We can learn more about the return values by looking at its TypeScript interface, below.
interface UseFindData {
items: Ref<any>
paginationData: Ref<object>
servicePath: Ref<string>
qid: Ref<string>
isPending: Ref<boolean>
haveBeenRequested: Ref<boolean>
haveLoaded: Ref<boolean>
error: Ref<Error>
debounceTime: Ref<number>
latestQuery: Ref<object>
isLocal: Ref<boolean>
find: Function
isSsr: Ref<boolean>
request: Ref<Promise<Request> | null>
}
Let's look at the functionality that each one provides:
items
is the list of results. By default, this list will be reactive, so if new items are created which match the query, they will show up in this list automagically.servicePath
is the FeathersJS service path that is used by the current model. This is mostly only useful for debugging.isPending
is a boolean that indicates if there is an active query. It is set totrue
just before each outgoing request. It is set tofalse
after the response returns. Bind to it in the UI to show an activity indicator to the user.haveBeenRequested
is a boolean that is set totrue
immediately before the first query is sent out. It remains true throughout the life of the component. This comes in handy for first-load scenarios in the UI.haveLoaded
is a boolean that is set to true after the first API response. It remainstrue
for the life of the component. This also comes in handy for first-load scenarios in the UI.isLocal
is a boolean that is set to true if this data is local only.qid
is currently the primaryqid
provided in params. It might become more useful in the future.debounceTime
is the current number of milliseconds used as the debounce interval.latestQuery
is an object that holds the latest query information. It populates after each successful API response. The information it contains can be used to pull data from thepaginationData
.paginationData
is an object containing all of the pagination data for the current service.error
is null until an API error occurs. The error object will be serialized into a plain object and available here.find
is the find method used internally. You can manually make API requests. This is most useful for when you havepaginate: true
in the params. You can manually query refreshed data from the server, when desired. Callingfind
actually calls in the internalfindProxy
, so if you havedebounceTime
set, requests will be debounced.isSsr
is a boolean that matches the value of thessr
option in eithersetupFeathersPinia
ordefineStore
.request
will contain the promise for any active request.
Conditionally Running Queries
There are two ways of controlling whether or not queries go out.
- Return
null
in theparams
orfetchParams
. (Vue 3, only) - Use the
queryWhen
property. This is the recommended option.
The queryWhen
property accepts a computed property that returns either a boolean OR a function that returns a boolean.
queryWhen
as a Computed Boolean
The below example uses a boolean. No query is made initially, because queryWhen
returns false. When the timeout sets isReady
to true, queryWhen
returns true and Vue's wonderful reactivity layer automagically fires the request. If you were to toggle isReady
, each time it evaluates to true
the request will go out again.
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useFindWatched } from 'feathers-pinia'
import { useUsers } from '~/store/users.ts'
const userStore = useUsers()
const isReady = ref(false)
const params = computed(() => {
return { query: { $limit: 10, $skip: 0 } }
})
const queryWhen = computed(() => isReady.value)
const { items } = useFindWatched({
model: userStore.Model,
params,
queryWhen
})
setTimeout(() => {
isReady.value = true
}, 5000)
</script>
queryWhen
as a Computed Function
The queryWhen
property can also be implemented as a computed property that returns a function. The function receives a context
object and needs to return a boolean. The context
object has some useful information that you can use to determine whether to return true
or false
. Here's what context
looks like:
export interface QueryWhenContext {
items: ComputedRef<AnyData[]>
queryInfo: QueryInfo
qidData: PaginationStateQid
queryData: PaginationStateQuery
pageData: PaginationStatePage
}
The qidData
, queryData
, and pageData
properties all come from the service store's pagination
object, which contains useful information for every query. Let's review the pagination
object before we see how to use the context
object.
BE CAREFUL when using queryWhen
as a computed function because it can cause infinite loops when the return value is truthy. The computed value reruns whenever the data in the QueryWhenContext
changes. The general way to avoid recomputes is to make sure you toggle the value to false after the response comes back. Leaving it truthy will likely cause the infinite looping behavior.
Pagination State
The qid
, queryId
, and pageId
in the below structure are all determined by the attributes in the params.
qid
comes fromparams.qid
. All queries with the sameqid
will be kept in the same object. ThemostRecentQuery
attribute contains queryInfo about where in the pagination structure you'll find the most recent query.queryId
is a stringified representation of all attributes inparams.query
except$limit
and$skip
.pageId
is a stringified representation of$limit
and$skip
, which are the page-level attributes.
// This is a pseudo-TypeScript interface that illustrates the pagination structure.
interface PaginationState {
[qid: string]: { // This level is the `qidData`
[queryId: string]: { // This level is the `queryData`
[pageId: string]: { // This level is the `pageData`
ids: Id[]
pageParams: QueryPagination
queriedAt: number // timestamp
ssr: boolean
}
queryParams: Query
total: number
}
mostRecent: MostRecentQuery
}
}
interface MostRecentQuery {
pageId: string
pageParams: QueryPagination
queriedAt: number
query: Query
queryId: string
queryParams: Query
total: number
}
queryWhen
Function Example
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useFindWatched } from 'feathers-pinia'
import { useUsers } from '~/store/users.ts'
const userStore = useUsers()
const params = computed(() => {
return { query: { $limit: 10, $skip: 0 } }
})
// Notice the two arrow functions. This is a computed that returns a function.
// Lots of return examples here. Not valid JS, just for illustration. ;)
const queryWhen = computed(() => (context) => {
const { items, queryInfo, qidData, queryData, pageData } = context
// Allow the query if we don't have any items. If you do this you have to manually call find(), as done in the timeout.
return !items.length
// Allow the query if over 5 minutes have passed
return !pageData || pageData?.queriedAt < new Date.getTime() - 300_000
})
const { items, find } = useFindWatched({
model: userStore.Model,
params,
queryWhen
})
setTimeout(() => {
find()
}, 5000)
</script>
A couple of best practices
- Always use
$limit
and$skip
in queries. This allows Feathers-Pinia to store more accurate pagination data. - Recognize that pagination-related objects will be
undefined
until after the first query response. This is why we use the conditional inpageData?
.queriedAt`. It will only exist if a matching query has previously been made.
Working with Refs
Pay special attention to the properties of type Ref
, in the TypeScript interface, above. Those properties are Vue Composition API ref
instances. This means that you need to reference their value by using .value
. In the next example the completeTodos
and incompleteTodos
are derived from the todos
, using todos.value
<template>
<div>
<li
v-for="tutorial in tutorials"
:key="tutorial.id"
>
{{ tutorial.name }}
</li>
</div>
</template>
<script setup>
import { useFindWatched } from 'feathers-pinia'
import { useTodos } from '../store/todos'
const todoStore = useTodos()
const params = computed(() => {
return {
query: {},
}
})
const { items: todos } = useFindWatched({ model: todoStore.Todo, params })
// Notice the "todos.value"
const completeTodos = computed(() => todos.value.filter((todo) => todo.isComplete))
const incompleteTodos = computed(() => todos.value.filter((todo) => !todo.isComplete))
</script>
Troubleshooting Missing Paginated Data
One problem that can occur when you have reactive params.query is it's possible to modify the params in a hook. The most common problems happen with hooks that modify the query, like paramsFromServer
from either feathers-hooks-common
or feathers-graph-populate
. If you are using any hook that modifies params.query
, you need to make a shallow clone of the object to prevent the reactive one you provide to useFindWatched
from getting modified. useFindWatched
uses the original query object to process the incoming paginated data. So if the original query gets changed by downstream hooks, no data will show up, and no errors will show, either.
Steps to troubleshoot missing data:
- Make sure you see your data arriving in the Chrome Dev Tools Network tab.
- Turn off (comment out) any hooks on outbound requests and see if your data shows up again.
- If you can't turn off hooks, try adding a global hook that creates a shallow copy of the params:
app.hooks({
before: {
all: [
// Replace context.params with a shallow clone to prevent future hooks from messing with the reactive query object.
context => {
const query = { ...context.params.query }
context.params = { ...context.params, query }
}
]
}
})