🎁 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, theModelproperty can be pulled from the store. - Create a computed property for the params. Return an object with a nested
queryobject. - 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:
modelmust be a Feathers-Pinia Model class. The Model'sfindandfindInStoremethods are used to query data.paramsis a FeathersJS Params object OR a Composition APIref(orcomputed, since they return arefinstance) 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
nullwill prevent an API request from being made. - You can use
params.qidto dynamically specify the query identifier for any API request. Theqidis used for tracking pagination data and enabling the fall-through cache across multiple queries. - Set
params.paginatetotrueto 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.debounceto an integer and the API requests will automatically be debounced by that many milliseconds. For example, settingdebounce: 1000will assure that the API request will be made at most every 1 second. - Set
params.tempstotrueto 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.copiestotrueto include cloned items in the results. The queried items get replaced with the corresponding copies fromcopiesById
- When provided alone (without the optional
fetchParamsThis is a separate set of params that, when provided, will become the params sent to the API server. Theparamswill then only be used to query data from the local data store.- Explicitly returning
nullwill prevent an API request from being made (but only for Vue 3. For Vue 2, usequeryWhen).
- Explicitly returning
queryWhenprovides a logical separation for preventing API requests outside of theparams. It must be acomputedproperty that returns one of the following:- a
boolean - a
QueryWhenFunction, receiving aQueryWhenContextand returning a boolean.
- a
qidallows you to specify a query identifier (used in the pagination data in the store). This can also be set dynamically by returning aqidin the params.immediate, which istrueby default, determines if the internalwatchshould fire immediately. Setimmediate: falseand 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:
itemsis 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.servicePathis the FeathersJS service path that is used by the current model. This is mostly only useful for debugging.isPendingis a boolean that indicates if there is an active query. It is set totruejust before each outgoing request. It is set tofalseafter the response returns. Bind to it in the UI to show an activity indicator to the user.haveBeenRequestedis a boolean that is set totrueimmediately 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.haveLoadedis a boolean that is set to true after the first API response. It remainstruefor the life of the component. This also comes in handy for first-load scenarios in the UI.isLocalis a boolean that is set to true if this data is local only.qidis currently the primaryqidprovided in params. It might become more useful in the future.debounceTimeis the current number of milliseconds used as the debounce interval.latestQueryis 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.paginationDatais an object containing all of the pagination data for the current service.erroris null until an API error occurs. The error object will be serialized into a plain object and available here.findis the find method used internally. You can manually make API requests. This is most useful for when you havepaginate: truein the params. You can manually query refreshed data from the server, when desired. Callingfindactually calls in the internalfindProxy, so if you havedebounceTimeset, requests will be debounced.isSsris a boolean that matches the value of thessroption in eithersetupFeathersPiniaordefineStore.requestwill contain the promise for any active request.
Conditionally Running Queries
There are two ways of controlling whether or not queries go out.
- Return
nullin theparamsorfetchParams. (Vue 3, only) - Use the
queryWhenproperty. 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.
qidcomes fromparams.qid. All queries with the sameqidwill be kept in the same object. ThemostRecentQueryattribute contains queryInfo about where in the pagination structure you'll find the most recent query.queryIdis a stringified representation of all attributes inparams.queryexcept$limitand$skip.pageIdis a stringified representation of$limitand$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
$limitand$skipin queries. This allows Feathers-Pinia to store more accurate pagination data. - Recognize that pagination-related objects will be
undefineduntil 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 }
}
]
}
})