Introduction
In previous articles, we discussed how to elegantly handle data loading in Vue and pointed out some issues and practical solutions related to fetching external data. This time, I want to explore how to build a better frontend data fetching experience through TanStack Query.
“Server State” in Frontend
- Is persisted remotely in a location you may not control or own
- Requires asynchronous APIs for fetching and updating
- Implies shared ownership and can be changed by other people without your knowledge
- Can potentially become “out of date” in your applications if you’re not careful
Frontends frequently interact with “Server State,” making state fetching, management, synchronization, and caching painful issues to handle. In practice, teams often face “chaotic state management” and “duplicate logic,” which are often related to server state.
I must say I’ve spent too much time in my frontend career dealing with and managing server state. I hope that using the right tool like TanStack Query can significantly improve the asynchronous state development experience and even the user experience!
What Problems Does TanStack Query Solve?
- Automatic caching + automatic refetching (Stale While Revalidate, window refocusing, polling)
- Parallel + Dependent Queries
- Mutations (Server side effect)
- Multi-layer caching + Automatic garbage collection
- Pagination + Cursor-based queries
- Infinite loading queries
- Request cancellation
- Dedicated developer tools
More advanced meta frameworks like Next or Nuxt have established ways and integrated data fetching solutions that you are encouraged to use. On the other hand, TanStack Query is a lightweight asynchronous state management solution that supports all popular frontend frameworks, starting from the following simple example and three concepts: useQuery
, queryKey
, queryFn
.
<script setup>import { useQuery } from '@tanstack/vue-query'
const { isPending, isFetching, isError, data, error } = useQuery({ queryKey: ['todos'], queryFn: getTodos,})</script>
useQuery
is a (Composable / Hook) that manages and returns statequeryKey
is a unique identifying array of strings used to determine cache and reloadqueryFn
is a function that returns a Promise
const fetchTodos = (): Promise<Todo[]> => axios.get('/todos').then((response) => response.data)
Automatic Caching and Data Synchronization
Each API request will be automatically cached and identified and merged based on the queryKey
. When using the same queryKey
in different components, only one network request will be executed, and the result will be shared. This means there is no need to re-request when switching pages, saving resources and improving the user experience.
Background Updates
Stale-While-Revalidate (SWR): “Respond immediately to users with old data while updating new data in the background for the next update.”
Data may be updated at any time on the server side by other users or actions. TanStack Query supports automatic or manual triggering of “background updates,” allowing UI to stay updated without interrupting the user workflow. For example, when scrolling through a list, it won’t flicker, but the background has already been updated silently.
Stale Logic
const { isPending, isFetching, isError, data, error } = useQuery({ queryKey: ['todos'], queryFn: getTodos, staleTime: 60 * 1000, // 1 minute})
You can finely control when data is considered “stale” by using the staleTime parameter to tell the system how long data is valid, preventing API triggers on every render. This helps strike a balance between “real-time data” and “performance”
Parallel Fetching
Native JavaScript executes async
await
in a concurrent manner unless using Promise.all
, while TanStack Query maximizes parallel execution by default and provides a more elegant way to manage request status:
// Demo-1await fetchUser();await fetchPosts();
// Demo-2await Promise.all([ fetchUser(), fetchPosts(), fetchComments(),]);
// TanStack Queryconst usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })const teamsQuery = useQuery({ queryKey: ['teams'], queryFn: fetchTeams })const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects,})
Dependent Fetching
One Query often depends on another Query; for instance, needing to fetch a certain user before fetching that user’s resources:
// Get the userconst { data: user } = useQuery({ queryKey: ['user', email], queryFn: () => getUserByEmail(email.value),})
const userId = computed(() => user.value?.id)const enabled = computed(() => !!user.value?.id)
// Then get the user's projectsconst { isIdle, data: projects } = useQuery({ queryKey: ['projects', userId], queryFn: () => getProjectsByUser(userId.value), enabled, // The query will not execute until `enabled == true`})
Using useMutation for Side Effects
const { mutate, isPending } = useMutation({ mutationFn: async (newTodo) => { const { data } = await axios.post("/todos", newTodo) return data }, onSucess: () => { queryClient.invalidateQueries({ queryKey: ["todos"]}) }})
Compared to the declarative data reading of useQuery
, useMutation
handles side effects (creation, updating, deletion) imperatively and can manually call invalidateQueries
to invalidate related caches and automatically refresh data. This eliminates the need to manually maintain data synchronization logic.
Conclusion
Basically, useQuery
, useMutation
, and invalidateQueries
are sufficient to manage most CRUD applications. Some advanced features like optimistic updates and pagination can be explored further in the official documentation.
Further Reading
- React Query in 100 Seconds - Fireship
- Deep Dive into TanStack Query (Part 1): What Happens After Calling useQuery
- Deep Dive into TanStack Query (Part 2): What Happens After Calling useMutation
- Deep Dive into TanStack Query (Part 3): What Happens After Calling invalidateQueries
- Vue3 Tutorial - Vue Query Part.1 Using useQuery and useMutation