- Start Date: 2022-07-14
- Target Major Version: Vue 3, Vue Router 4
- Reference Issues:
- Implementation PR: - https://github.com/posva/unplugin-vue-router/tree/main/src/data-fetching
List of things that haven't been added to the document yet:
-
Show how to use the data loader withoutvue-router/auto
-
Explain whatvue-router/auto
brings - Extendable API for data fetching libraries like vue-apollo, vuefire, vue-query, etc
There is no silver bullet to data fetching because of the different data fetching strategies and how they can define the architecture of the application and its UX. However, I think it's possible to find a solution that is flexible enough to promote good practices and reduce the complexity of data fetching in applications. That is the goal of this RFC, to standardize and improve data fetching with vue-router:
- Integrate data fetching to the navigation cycle (or not by making it lazy/non-blocking)
- Dedupe Requests
- Delay data updates until all data loaders are resolved
- Allow parallel or sequential data fetching (loaders that depends on the result of other loaders)
- Without needing Suspense to avoid cascading requests
- Provide control over loading/error states
- Define a set of Interfaces that enable other libraries like vue-apollo, vue-query, etc to implement their own loaders that can be used with the same API
This proposal concerns Vue Router 4 and is implemented under unplugin-vue-router. Some features, like typed routes, are only available with file-based routing but this is not required.
We define loaders anywhere and attach them to page components (components associated to a route). They return a composable that can be used in any component (not only pages).
A loader can be attached to a page in two ways: by being exported or by being added to the route definition.
Exported from a non-setup <script>
in a page component:
<script lang="ts">
import { getUserById } from '../api'
import { defineLoader } from 'vue-router'
// name the loader however you want **and export it**
export const useUserData = defineLoader(async (route) => {
const user = await getUserById(route.params.id)
// ...
// return anything you want to expose
return user
})
// Optional: define other component options
export default defineComponent({
name: 'custom-name',
inheritAttrs: false
})
</script>
<script lang="ts" setup>
// find the user as `data` and some other properties
const { data: user, pending, error, refresh } = useUserData()
// data is always present, pending changes when going from '/users/2' to '/users/3'
</script>
When a loader is exported by the page component, it is automatically picked up as long as the route is lazy loaded (which is a best practice). If the route isn't lazy loaded, the loader can be directly defined in an array of loaders on meta.loaders
:
import { createRouter } from 'vue-router'
import UserList from '@/pages/UserList.vue'
// could be anywhere
import { useUserList } from '@/loaders/users'
export const router = createRouter({
// ...
routes: [
{
path: '/users',
component: UserList,
meta: {
// Required when the component is not lazy loaded
loaders: [useUserList]
}
},
{
path: '/users/:id',
// automatically picks up all exported loaders
component: () => import('@/pages/UserDetails.vue')
}
]
})
user
,pending
, anderror
are refs and therefore reactive.refresh
is a function that can be called to force a refresh of the data without a new navigation.useUserData()
can be used in any component, not only in the one that defines it. We import the function and call it within<script setup>
like other composables- Define and use Data Loaders anywhere. Export them in page components to attach them to pages.
In each of these cases, the data loaders block the navigation, meaning it integrates transparently with SSR and any errors can be handled at the router level. On top of that, data loaders are deduped, which means that no mather how many times you use the same loader in different places, it will still load the data just once.
The simplest of data loaders can be defined in just one line and types will be automatically inferred:
// function fetchBookCollection(): Promise<Books[]>
export const useBookCollection = defineLoader(fetchBookCollection)
Note that this syntax will intentionally be avoided in the RFC. Instead, we will often use slightly longer examples to make things easier to follow.
There are currently too many ways of handling data fetching with vue-router and all of them have problems:
- With navigation guards:
- using
meta
: complex to setup even for simple cases, too low level for such a common case - using
onBeforeRouteUpdate()
: missing data when entering tha page beforeRouteEnter()
: non typed and non-ergonomic API withnext()
, requires a data store (pinia, vuex, apollo, etc)
- using
- using a watcher on
route.params...
: component renders without the data (doesn't work with SSR) - TODO: using suspense?
People are left with a low level API (navigation guards) to handle data fetching themselves. This is often a difficult problem to solve because it requires an extensive knowledge of the Router concepts and in reality, very few people know them.
Thus, the goal of this proposal is to provide a simple yet configurable way of defining data loading in your application that is easy to understand and use. It should also be compatible with SSR and not limited to simple fetch calls. It should be adoptable by frameworks like Nuxt.js to provide an augmented data fetching layer that integrates well with Vue.js Concepts and the future of Web APIs.
There are features that are out of scope for this proposal but should be implementable in user-land thanks to an extendable API:
- Implement a full-fledged cached API like vue-query
- Implement pagination
- Automatically refetch data when outside of navigations (e.g. there is no intention to implement advanced APIs such as
refetchInterval
, refetch on focus, etc)
This RFC also aims to integrate data fetching within navigations while still not forcing you to block navigation with data fetching. This pattern is useful for multiple reasons:
- Ensure data is present before mounting the component (blocks navigation)
- Flexibility to not wait for non critical data with lazy data loaders
- Enables the UX pattern of letting the browser handle loading state (aligns better with future browser APIs)
- Makes scrolling work out of the box when navigating between pages (when data is blocking)
- Ensure one single request per loader and navigation
- Extremely lightweight compared to more complex fetching solutions like vue-query/tastack-query, apollo/graphql, etc
Note
In the examples, Data Loaders are used alongside unplugin-vue-router to provide typed routes but it can be used outside. At the moment, Data Loaders are implemented in that plugin as an experiment, check the Experimental Data fetching.
In its simplest form, the basic implementation for defineLoader()
in unplugin-vue-router takes a function that returns a promise (of data) and returns a composable that can be used in any component, not only in the one that defines it. We call these loaders. Loaders can be declared anywhere but must be exported by page components in order for them to get picked up and executed during the navigation. They receive the target route
as well as other properties, as an argument and must return a Promise. The resolved value will then be directly accessible as a ref when calling the composable.
Limiting the loader access to only the target route, ensures that the data can be fetched when the user refreshes the page. In enforces a good practice of correctly storing the necessary information in the route as params or query params to create sharable URLs. Within loaders there is no access to the current component or page instance, but it's possible to access global injections created with app.provide()
. This includes stores created with Pinia.
Loaders also have the advantage of behaving as singleton requests. This means that they are only fetched once per navigation no matter how many times the loader is attached or how many regular components use it. It also means that all the refs (data
, pending
, etc) are created only once and shared by all components, reducing memory usage.
Tip
This is done automatically in unplugin-vue-router.
The examples below import from vue-router
but it's not sure if this API will be exported from there in the future.
To enable data loaders, we need to setup the navigation guard that handles it:
import { setupLoaderGuard, createRouter } from 'vue-router'
const router = createRouter({
// ...
})
export function setupLoaderGuard({
router,
app // vue createApp()
})
When doing Lazy loading, the loader will be automatically picked up by the navigation guard:
// lazy loaded route
const routes = [
{
path: '/users/:id',
// automatically picks up any loader exported
component: () => import('@/pages/UserDetails.vue')
}
]
If the route isn't lazy loaded, the loader can be directly defined in an array of loaders on meta.loaders
:
import { useUserDetails } from '@/loaders/users'
import UserDetails from '@/pages/UserDetails.vue'
const routes = [
{
path: '/users/:id',
component: UserDetails,
meta: {
// must be added to the meta property
loaders: [useUserDetails]
}
}
]
When using vue router named views, each named view can have their own loaders but note any navigation to the route will trigger all loaders from all page components.
Tip
Note: with unplugin-vue-router, a named view can be declared by appending @name
at the end of the file name:
src/pages/
└── users/
├── index.vue
└── [email protected]
This creates a components: { default: ..., aux: ... }
entry in the route config.
defineLoader()
returns a composable with the following properties:
const useLoader = defineLoader(...)
const {
data, // Ref<T> T being the awaited return type of the function passed to `defineLoader()`
pending, // Ref<boolean>
error, // Ref<any>
refresh, // () => Promise<void>
} = useLoader()
data
contains the resolved value returned by the loaderpending
istrue
while a request is pending and becomesfalse
once the request is settlederror
becomesnull
each time a request starts and is filled with the error thrown by the loaderrefresh()
invokes the loader (an internal version that sets thepending
and other flags)
In practice, rename data
(or others) to something more meaningful:
import { getUserById } from '@/api/users'
const useUserData = defineLoader(async ({ params }) => {
const user = await getUserById(params.id)
return user // can be anything
})
const {
// ... same as above
data: user // Ref<UserData>
} = useUserData()
defineLoader()
can be passed some options to customize its behavior
setupLoaderGuard()
setups a navigation guard that handles all the loaders. It has a few options
The Vue app instance created with createApp()
The Vue Router instance.
Allows setting the initial data and skip the first fetch for SSR apps.
Called wih an array of NavigationResult
returned by loaders. It allows to decide the fate of the navigation.
Note this isn't called if no data loaders return a NavigationResult
or if an error or NavigationResult
is thrown. In that case, the first throw will take precedence.
By default, selectNavigation
returns the first value of the array.
TODO: move this lower
The goal if this API is to also expose an implementable interface for external libraries to create more advanced defineLoaders(). For example, vue-query could be directly used to create a custom define loader that handles caching and other advanced features.
- Discard pending loaders with new navigations even within nested loaders and when discarded loaders resolve later
By default, loaders are executed as soon as possible, in parallel. This scenario works well for most use cases where data fetching only requires route params/query params or nothing at all.
Sometimes, requests depend on other fetched data (e.g. fetching additional user information). For these scenarios, we can simply import the other loaders and use them within a different loader:
Call and await
the loader inside the one that needs it, it will only be fetched once no matter how many times it is called:
// import the loader for user information
import { useUserData } from '@/loaders/users'
export const useUserCommonFriends = defineLoader(async (route) => {
// loaders must be awaited inside other loaders
// . ⤵
const user = await useUserData() // magically works
// fetch other data
const commonFriends = await getCommonFriends(user.value.id)
return { ...user, commonFriends }
})
You will notice here that we have two different usages for useUserData()
:
- One that returns all the necessary information we need synchronously (not used here). This is the composable that we use in components
- A second version that only returns a promise of the data. This is the version used within data loaders
Since useUserCommonFriends()
loader calls useUserData()
, if useUserData()
's cache expires or gets manually invalidated, it will also automatically invalidate useUserCommonFriends()
.
Note that two loaders cannot use each other as that would create a dead lock.
This can get complex with multiple pages exposing the same loader and other pages using some of this already exported loaders within other loaders. But it's not an issue, the user shouldn't need to handle anything differently, loaders are still only called once:
import { getFriends, getUserById } from '@/api/users'
export const useUserData = defineLoader(async (route) => {
const user = await getUserById(route.params.id)
return user
})
export const useCurrentUserData(async route => {
const me = await getCurrentUser()
// imagine legacy APIs that cannot be grouped into one single fetch
const friends = await getFriends(user.value.id)
return { ...me, friends }
})
export const useUserCommonFriends = defineLoader(async (route) => {
const user = await useUserData()
const me = await useCurrentUserData()
const friends = await getCommonFriends(user.id, me.id)
return { ...me, commonFriends: { with: user, friends } }
})
In the example above we are exporting multiple loaders but we don't need to care about the order in which they are called nor try optimizing them because they are only called once and share the data.
Caveat: must call and await all nested loaders at the top of the parent loader (see useUserData()
and useCurrentUserData()
). You cannot put a different regular await
in between. If you really need to await anything that isn't a loader in between, wrap the promise with withDataContext()
to ensure the loader context is properly restored:
export const useUserCommonFriends = defineLoader(async (route) => {
const user = await useUserData()
await withContext(functionThatReturnsAPromise())
const me = await useCurrentUserData()
// ...
})
This allows nested loaders to be aware of their parent loader. This could probably be linted with an eslint plugin. It is similar to the problem <script setup>
had before introducing the automatic withAsyncContext()
. The same feature could be introduced but will also have a performance cost.
Each loader has its own cache and it's not shared across multiple application instances as long as they use a different router
instance (the router
is internally used as the key of a WeakMap()
). This aligns with the recommendation of using one router instance per request in the ecosystem and usually one won't even need to know about this.
The cache is a very simple time based expiration cache that defaults to 5 seconds. When a loader is called, it will wait 5s before calling the loader function again. This can be changed with the cacheTime
option:
defineLoader(..., { cacheTime: 1000 * 60 * 5 }) // 5 minutes
defineLoader(..., { cacheTime: 0 }) // No cache (still avoids calling the loader twice in parallel)
defineLoader(..., { cacheTime: Infinity }) // Cache forever
TODO: give the loaders the ability to know what was the last location?
When navigating, the data is refreshed automatically based on what params, query params, and hash are used within the loader.
Given this loader in page /users/:id
:
export const useUserData = defineLoader(async (route) => {
const user = await getUserById(route.params.id)
return user
})
Going from /users/1
to /users/2
will refresh the data but going from /users/2
to /users/2#projects
will not unless the cache expires or is manually invalidated.
Manually call the refresh()
function to execute the loader again. Depending on the implementation this will end up in a new request or not. For example, a basic loader could always refetch, while a more advanced one, like Pinia Colada will only refetch if the cached value is stale or if any of the parameters change.
<script setup>
import { useInterval } from '@vueuse/core'
import { useUserData } from '@/pages/users/[id].vue'
const { data: user, refresh } = useUserData()
// refresh the data every 10s
useInterval(refresh, 10000)
</script>
<template>
<div>
<h1>User: {{ user.value.name }}</h1>
</div>
</template>
Loaders can be attached to a page even if the page component doesn't use it (invoke the composable returned by defineLoader()
). It can be used in any component by importing the returned composable, even outside of the scope of the page components, even by a parent.
On top of that, loaders can be defined anywhere and imported where using the data makes sense. This allows to define loaders in a separate src/loaders
folder and reuse them across pages:
// src/loaders/user.ts
export const useUserData = defineLoader(...)
// ...
Ensure it is exported by page components:
<!-- src/pages/users/[id].vue -->
<script>
export { useUserData } from '@/loaders/user.ts'
</script>
<script setup>
// ...
</script>
You can still use it anywhere else:
<!-- src/components/NavBar.vue -->
<script setup>
import { useUserData } from '@/loaders/user.ts'
const { data: user } = useUserData()
</script>
In such scenarios, it makes more sense to move the loader to a separate file to ensure better code splitting.
Types are automatically generated for the routes by unplugin-vue-router and can be referenced with the name of each route to hint defineLoader()
the possible values of the current types:
<script lang="ts">
import { getUserById } from '../api'
import { defineLoader } from 'vue-router'
export const useUserData = defineLoader('/users/[id]', async (route) => {
// ^ autocompleted by unplugin-vue-router ✨
const user = await getUserById(route.params.id)
// ^ typed!
// ...
return user
})
</script>
<script lang="ts" setup>
const { data: user, pending, error } = useUserData()
</script>
The arguments can be removed during the compilation step in production mode since they are only used for types and are ignored at runtime.
Also known as lazy async data in Nuxt, loaders can be marked as lazy to not block the navigation.
<script lang="ts">
import { getUserById } from '../api'
export const useUserData = defineLoader(
async (route) => {
const user = await getUserById(route.params.id)
return user
},
{ lazy: true } // 👈 marked as lazy
)
</script>
<script setup>
// Differently from the example above, `user.value` can and will be initially `undefined`
const { data: user, pending, error } = useUserData()
// ^ Ref<User | undefined>
</script>
This patterns is useful to avoid blocking the navigation while less important data is being fetched. It will display the page earlier while some of the parts of it are still loading and you are able to display loader indicators thanks to the pending
property.
Note this still allows for having different behavior during SSR and client side navigation, e.g.: if we want to wait for the loader during SSR but not during client side navigation:
export const useUserData = defineLoader(
async (route) => {
// ...
},
{
lazy: !import.env.SSR, // Vite
lazy: process.client, // NuxtJS
)
Existing questions:
Should it be possible to await all pending loaders withawait allPendingLoaders()
? Is it useful for SSR? Otherwise we could always ignore lazy loaders in SSR. Do we need both? Do we need to selectively await some of them?- Should we be able to transform a loader into a lazy version of it:
const useUserDataLazy = asLazyLoader(useUserData)
Since the data fetching happens within a navigation guard, it's possible to control the navigation like in regular navigation guards:
- Thrown errors (or rejected Promises) cancel the navigation (same behavior as in a regular navigation guard) and are intercepted by Vue Router's error handling
- Redirection:
return new NavigationResult(targetLocation)
-> likereturn targetLocation
in a regular navigation guard - Cancelling the navigation:
return new NavigationResult(false)
likereturn false
in a regular navigation guard
import { NavigationResult } from 'vue-router'
export const useUserData = defineLoader(
async ({ params, path ,query, hash }) => {
try {
const user = await getUserById(params.id)
return user
} catch (error) {
if (error.status === 404) {
return new NavigationResult({ name: 'not-found', params: { pathMatch: } }
)
} else {
throw error // aborts the vue router navigation
}
}
}
)
new NavigationResult()
accepts as its only constructor argument, anything that can be returned in a navigation guard.
Some alternatives:
createNavigationResult()
: too verboseNavigationResult()
(nonew
):NavigationResult
is not a primitive so it should usenew
The only difference between throwing an error and returning a NavigationResult
of an error is that the latter will still trigger the selectNavigationResult()
mentioned right below while a thrown error will always take the priority.
Since navigation loaders can run in parallel, they can return different navigation results as well. In this case, you can decide which result should be used by providing a selectNavigationResult()
method to setupLoaderGuard()
:
setupLoaderGuard(router, {
selectNavigationResult(results) {
// results is an array of the unwrapped results passed to `new NavigationResult()`
return results.find((result) => result.name === 'not-found')
}
})
selectNavigationResult()
will be called with an array of the unwrapped results passed to new NavigationResult()
after all data loaders have been resolved. If any of them throws an error or if none of them return a NavigationResult
, selectNavigationResult()
won't be called.
If a loader wants to eagerly change the navigation, it can throw
the NavigationResult
instead of returning it. This will skip the selectNavigationResult()
and take precedence.
import { NavigationResult } from 'vue-router'
export const useUserData = defineLoader(
async ({ params, path ,query, hash }) => {
try {
const user = await getUserById(params.id)
return user
} catch (error) {
throw new NavigationResult(
{ name: 'not-found', params: { pathMatch: } }
)
}
}
)
The loader receives in a second argument access to an AbortSignal
that can be passed on to fetch
and other Web APIs. If the navigation is cancelled because of errors or a new navigation, the signal aborts, causing any request using it abort as well.
export const useBookCatalog = defineLoader(async (_route, { signal }) => {
const books = markRaw(await getBookCatalog({ signal }))
return books
})
This aligns with the future Navigation API and other web APIs that use the AbortSignal
to cancel an ongoing invocation.
To support SSR we need to do two things:
- A way to serialize each data loaded on the server with a unique key. Note: Would an array work? I don't think the order of execution is guaranteed.
- On the client side, pass the initial state to
setupLoaderGuard()
. The initial state is used once and discarded afterwards.
Different implementations could have different kind of keys. The simplest form is a string:
export const useBookCollection = defineLoader(
async () => {
const books = await fetchBookCollection()
return books
},
{ key: 'bookCollection' }
)
The configuration of setupLoaderGuard()
depends on the SSR configuration, here is an example with vite-ssg:
import { ViteSSG } from 'vite-ssg'
import { setupLoaderGuard } from 'vue-router'
import App from './App.vue'
import { routes } from './routes'
export const createApp = ViteSSG(
App,
{ routes },
async ({ router, isClient, initialState }) => {
// fetchedData will be populated during navigation
const fetchedData = setupLoaderGuard(router, {
initialData: isClient
? // on the client we pass the initial state
initialState.vueRouter
: // on server we want to generate the initial state
undefined
})
// on the server, we serialize the fetchedData
if (!isClient) {
initialState.vueRouter = fetchedData
}
}
)
Note that setupLoaderGuard()
should be called before app.use(router)
so it takes effect on the initial navigation. Otherwise a new navigation must be triggered after the navigation guard is added.
One of the advantages of having an initial state is that we can avoid fetching on the client, in fact, loaders are completely skipped on the client if the initial state is provided. This means nested loaders aren't executed either. Since data loaders shouldn't contain side effects besides data fetching, this shouldn't be a problem. Note that any loader without a key won't be serialized and will always be executed on both client and server.
When fetching large data sets that is never modified, it's convenient to mark the fetched data as raw before returning it:
export const useBookCatalog = defineLoader(async () => {
const books = markRaw(await getBookCatalog())
return books
})
An alternative would be to internally use shallowRef()
instead of ref()
inside defineLoader()
but that would prevent users from modifying the returned value and overall less convenient. Having to use markRaw()
seems like a good trade off in terms of API and performance.
It's possible to access a global state of when data loaders are fetching (during navigation or when refresh()
is called) as well as when the data fetching navigation guard is running (only when navigating).
isFetchingData: Ref<boolean>
: is any loader currently fetching data? e.g. calling therefresh()
method of a loaderisNavigationFetching: Ref<boolean>
: is navigation being hold by a loader? (impliesisFetchingData.value === true
). Calling therefresh()
method of a loader doesn't change this state.
TBD: is this worth it? Are any other functions needed?
Injections (They can nowinject
/provide
) cannot be used within a loader- Watchers and other composables shouldn't be used within data loaders:
- if
await
is used before calling a composable e.g.watch()
, the scope is not guaranteed - In practice, this shouldn't be a problem because there is no need to create composables within a loader
- if
- At first, it looks less intuitive than just awaiting something inside
setup()
with<Suspense>
but it doesn't have its limitations - Requires an extra
<script>
tag but only for page components. A macrodefinePageLoader()
/defineLoader()
could be error-prone as it's very tempting to use reactive state declared within the component's<script setup>
but that's not possible as the loader must be created outside of itssetup()
function
Using Suspense is probably the first alternative that comes to mind and it has been considered as a solution for data fetching by implementing proofs of concepts. It however suffer from major drawbacks that are tied to its current design and is not a viable solution for data fetching.
One could imagine being able to write something like:
<!-- src/pages/users.vue = /users -->
<!-- Displays a list of all users -->
<script setup>
const userList = shallowRef(await fetchUserList())
// manually expose a refresh function to be called whenever needed
function refresh() {
userList.value = await fetchUserList()
}
</script>
Or when params are involved in the data fetching:
<!-- src/pages/users.[id].vue = /users/:id -->
<!-- Displays a list of all users -->
<script setup>
const route = useRoute()
const user = shallowRef(await fetchUserData(route.params.id))
// manually expose a refresh function to be called whenever needed
function refresh() {
user.value = await fetchUserData(route.params.id)
}
// hook into navigation instead of a watcher because we want to block the navigation
onBeforeRouteUpdate(async (to) => {
// note how we need to use `to` and not `route` here
user.value = await fetchUserData(to.params.id)
})
</script>
Note
One of the reasons to block the navigation while fetching is to align with the upcoming Navigation API which will show a spinning indicator (same as when entering a URL) on the browser UI while the navigation is blocked.
This setup has many limitations:
-
Nested routes will force sequential data fetching: it's not possible to ensure an optimal parallel fetching
-
Manual data refreshing is necessary unless you add a
key
attribute to the<RouterView>
which will force a remount of the component on navigation. This is not ideal because it will remount the component on every navigation, even when the data is the same. It's necessary if you want to do a<transition>
but less flexible than the proposed solution which also works with akey
if needed. -
By putting the fetching logic within the
setup()
of the component we face other issues:- No abstraction of the fetching logic => code duplication when fetching the same data in multiple components
- No native way to dedupe requests among multiple components using them: it requires using a store and extra logic to skip redundant fetches (see bottom of Nested Invalidation )
- Requires mounting the upcoming page component (while the navigation is still blocked) which can be expensive in terms of rendering and memory as we still need to render the old page while we try to mount the new page.
-
No native way of caching data, even for very simple cases (e.g. no refetching when fast traveling back and forward through browser UI)
-
Not possible to precisely read (or write) the loading state (see vuejs/core#1347])
On top of this it's important to note that this RFC doesn't limit you: you can still use Suspense for data fetching or even use both, this API is completely tree shakable and doesn't add any runtime overhead if you don't use it. Keeping the progressive enhancement nature of Vue.js.
-
Allowing blocking data loaders to return objects of properties:
export const useUserData = defineLoader(async (route) => { const user = await getUserById(route.params.id) // instead of return user return { user } }) // instead of const { data: user } = useUserData() const { user } = useUserData()
This was the initial proposal but since this is not possible with lazy loaders it was more complex and less intuitive. Having one single version is overall easier to handle.
-
Adding a new
<script loader>
similar to<script setup>
:<script lang="ts" loader="useUserData"> import { getUserById } from '@/api/users' import { useRoute } from 'vue-router' // could be automatically imported const route = useRoute() // any variable created here is available in useLoader() const user = await getUserById(route.params.id) </script> <script lang="ts" setup> const { user, pending, error } = useUserData() </script>
Is exposing every variable a good idea?
-
Pass route properties instead of the whole
route
object:import { getUserById } from '../api' export const useUserData = defineLoader(async ({ params, query, hash }) => { const user = await getUserById(params.id) return { user } })
This has the problem of not being able to use the
route.name
to determine the correct typed params (with unplugin-vue-router):import { getUserById } from '../api' export const useUserData = defineLoader(async (route) => { if (route.name === 'user-details') { const user = await getUserById(params.id) // ^ Typed! return { user } } })
Variables could be named differently and proposals are welcome:
pending
(same as Nuxt) ->isPending
,isLoading
- Rename
defineLoader()
todefineDataFetching()
(or others)
-
Allowing
await getUserById()
could make people think they should also await inside<script setup>
and that would be a problem because it would force them to use<Suspense>
when they don't need to. -
Another alternative is to pass an array of loaders to the loader that needs them and let it retrieve them through an argument, but it feels considerably less ergonomic:
import { useUserData } from '@/pages/users/[id].vue' export const useUserFriends = defineLoader( async (route, { loaders: [userData] }) => { const friends = await getFriends(user.value.id) return { ...userData.value, friends } }, { // explicit dependencies waitFor: [useUserData] } )
The lazy
flag could be extended to also accept a number (timeout) or a function (dynamic value). I think this is too much and should therefore not be included.
Passing a number to lazy
could block the navigation for that number of milliseconds, then let it be:
<script lang="ts">
import { getUserById } from '../api'
export const useUserData = defineLoader(
async (route) => {
const user = await getUserById(route.params.id)
return user
},
// block the navigation for 1 second and then let the navigation go through
{ lazy: 1000 }
)
</script>
<script setup>
const { data, pending, error } = useUserData()
// ^ Ref<User | undefined>
</script>
Note that lazy loaders can only control their own blocking mechanism. They can't control the blocking of other loaders. If multiple loaders are being used and one of them is blocking, the navigation will be blocked until all of the blocking loaders are resolved.
A function could allow to conditionally block upon navigation:
export const useUserData = defineLoader(
loader,
// ...
{
lazy: (route) => {
// ...
return true // or a number
}
}
)
Introduce this as part of unplugin-vue-router to test it first and make it part of the router later on.
- Should there by an
afterLoad()
hook, similar tobeforeLoad()
? - What else is needed besides the
route
inside loaders? - Add option for placeholder data? Maybe some loaders should do that.
- What other operations might be necessary for users?