Introduction
Here at Weroad we move at a fast scale, experiencing a lot of growth, year after year, launching new products and tools for our customers every quarter.
From a dev perspective, this usually means that from time to time we have to address some of the technical debt that has accumulated in our frontends: new projects come and go, and keeping everything consistent and up to date ain’t an easy task.
In this regard, one of the hottest topics is making our projects move from nuxt 2 to nuxt 3, an upgrade that involves a high amount of breaking changes and sometimes total rewrite of some components (think about modules).
For some of the big projects we have, that equals to a lot of work.
To mitigate the impact of this migration, we opted for two main incremental strategies:
- Adopt the newly released Nuxt Bridge V3 and start writing every new functionality in a more modern way (we opted for composition api with
<script setup
as a standard for our components). - Modernize our api fetching strategies with a popular third-party library called TanStack Query (AKA React Query), another library we thoroughly use in our applications.
In this blog post we will understand why we opted for this API fetching library and we will show some of its main features and strengths.
We believe you’ll be convinced this is a good choice for making a step forward for your Nuxt application (it is available for nuxt3, too!)
useLazyFetch and its shortcomings
So let’s suppose we want to fetch information about a specific Rick and Morty episode (hint: it involves a pickle 🥒). We will be able to get all the info we need thanks to the publicly available Rick and Morty APIs.
By using useLazyFetch
composable given by our trusty nuxt-bridge, the code pretty straightforward
<script setup lang="ts"> const { data: episode, status } = useLazyFetch('https://rickandmortyapi.com/api/episode/24') </script> <template> <div> <Card :loading="status === 'pending'"> <div v-if="episode"> <h5 class="text-xl font-bold">{{ episode.name }}</h5> </div> </Card> </div> </template>
Unfortunately, useLazyFetch
falls short for everything but simple use cases, because we will lack all the following features that we consider very useful in medium to large frontend project:
- Global queries state management
- Stale time logic and automatic background fetching
- Customizable retry and error strategies
- Built in support for pagination and lazy loading
- Optimistic updates
- Dedicated dev tools
And here’s where Tanstack query really shines: it will give everything listed above out of the box and much more, in a battle tested library used by millions of packages.
So let’s try to add it to our project and refactor the code above.
⚙️ Vue query Setup
First and foremost, we will need to add the library to the project. We will use a package called vue-query, which is just the vue version of core library of Tanstack:
$ npm i @tanstack/[email protected] # or $ pnpm add @tanstack/[email protected] # or $ yarn add @tanstack/[email protected]
Why v4? Unfortunately the v5 version is incompatible with nuxt2, even with bridge. So we’ll stick with the v4 version.
We will also need to create a nuxt plugin to inject it in the application context, so let’s add a vue-query.ts in the plugin
folder of your nuxt2 project:
import Vue from 'vue' import { hydrate, QueryClient, VueQueryPlugin } from '@tanstack/vue-query' export default defineNuxtPlugin(nuxtApp => { // Here we can also set the default options for the QueryClient we'll use in the application, for example by specifying a different StaleTime or automatic refetch behaviour const queryClient = new QueryClient() Vue.use(VueQueryPlugin, { queryClient }) // If you are using SSR you will likely support the hydration of the library state between server and client // You can read more about it here: https://tanstack.com/query/v4/docs/framework/vue/guides/ssr if (process.client) { if (nuxtApp.nuxt2Context.nuxtState && nuxtApp.nuxt2Context.nuxtState.vueQueryState) { hydrate(queryClient, nuxtApp.nuxt2Context.nuxtState.vueQueryState) } } })
last but not least, remember to add the plugin in your nuxt.config.ts:
plugins: [ '~/plugins/vue-query.ts', ]
and that’s it, Vue Query v4 is ready for some work!
🎯 Queries and reactive data fetching
Replacing our useLazyFetch
implementation with an equivalent useQuery
is rather easy:
import { useQuery } from '@tanstack/vue-query' const { isLoading, data: episode } = useQuery({ queryKey: ['episode'], queryFn: () => $http.$get('https://rickandmortyapi.com/api/episode/24')), })
But there are two main differences in relation to the previous implementation:
- We specified a
queryKey
, which is a unique identifier of this query.
This will ensure that our request will be effectively managed and cached in the application (we’ll delve deep in this later, don’t worry). - We also specified a
queryFn()
function that has the responsibility to return a promise with the data requested.
It’s up to the user to build a proper fetching strategy for each single query: this enables the maximum flexibility on how the data should be retrieved.
But what about letting the user specify which episode to fetch? We could try by having a simple episodeNumber
ref bound to a <input type="number">
and using it to compose our query, so that it will automagically re-trigger at every change:
<script setup lang="ts"> import { useQuery } from '@tanstack/vue-query' const episodeNumber = ref(24) const { isLoading, status, data: episode } = useQuery({ queryKey: ['episode', episodeNumber], queryFn: ({ queryKey }) => $http.$get(`https://rickandmortyapi.com/api/episode/${queryKey[1]}`), }) </script> <template> <div> <Field label="Episode #"> <input v-model="episodeNumber" type="number"> </Field> <Card :loading="isLoading"> <h5 v-if="episode" class="text-xl font-bold">{{ episode.name }}</h5> </Card> </div> </template>
We can clearly see how the query itself reacts to the ref changing:
Also notice how the previously fetched episodes are showed instantaneously thanks to the query cache.
⏯️ Serial and parallel queries
But what about the characters? Could we show each one of them starring in a particular episode?
Fortunately, the api we are using already returns a list of URLs (as a property characters
) for each character in a episode.
So we’ll need to perform a query for each one url returned in the api/episode/
, making them effectively dependent on the main query and parallel.
To do that, we will just have to add in the script section:
import { useQueries } from '@tanstack/vue-query' const queries = computed(() => episode.value?.characters.map((characterURL) => { return { queryKey: ['character', characterURL], queryFn: ({ queryKey }) => $http.$get(queryKey[1]) } }) ?? [] ); const charactersQueries = useQueries({ queries })
- First, we map each
character
in a distinct query.
Because the results of the main query are not immediately available, we can fallback by creating an empty array thanks to the coalescing?? []
- Then, we use
useQueries
to dictate vue-query to run them in parallel
Next, we’ll render the results in the template:
<Card v-for="characterQ in charactersQueries" :key="characterQ?.data.id" class="h-56 min-h-56 p-4 flex" :loading="characterQ.isFetching || isFetching"> <div class="flex flex-1 flex-col gap-4" v-if="characterQ.data"> <h6 class="font-bold">{{ characterQ.data.name }}</h6> <img class="w-full h-full overflow-hidden object-contain" :src="characterQ.data.image" > </div> </Card>
The final result will be something like this:
We used “slow 3g” as throttler here. Notice how the already fetched queries are displayed istantaneusly
⁉️ Error handling
Currently, if we try to set episode number 52 and beyond, we’ll get stuck in a blank page.
As you may have been guessing, that’s because we did not set any error handling strategy in our code so far, so let’s try to add it in the application.
Firstly, we will add a global error handling in the application, so back in our vue-query.ts
plugin we configure our global queryClient
as follows:
const { $toast } = useNuxtApp() const queryClient = new QueryClient( { queryCache: new QueryCache({ onError: (error) => { // We need to add this type guard to make sure to infer the right type. // in V5 this will not necessary, as it will be an instance of Error by default if (error instanceof Error) { $toast.error(`Oh no! An ${error.message} error occurred. Please try again later or contact support`) } } }) })
Some notes on the code above:
$toast
is just a popup notification system from the handy nuxt toast module- This global error handling interceptor is the perfect place to push the error to a system-wide logging service like sentry or datadog
Obviously we can’t rely only on global error handling: sometimes we would like to show something more than a toast on screen.
For that, we will need to use the isError
and error
refs returned from the query:
const { isFetching, isError, data: episode, error } = useQuery(
and use them as we please in our episode Card:
<Card class="min-h-40 h-auto mt-10" error-state="isError" :loading="isFetching" clickable> <div v-if="isError"> <h5 class="text-xl font-bold">Dang! Something broke :(</h5> <p>{{error.stack}}</p> </div>
💬 Types support and final thoughts
So far so good, right? Well… mostly
Until now, we were basically working with lots of explicit and implicit any
: we had no clue on the actual type of the data returned from our queries, and we were basically telling to our templates Trust me bro, this property exists.
But can we actually do better? Can we actually achieve type safety in our queries in a maintainable, reasonable manner?
We will discuss this topic in more detail, along with other spicy concepts (for example, GraphQL integration and SDKs) in the next upcoming blog post.
Stay tuned!