Better Server State Management through TanStack Query!

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?

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 state
  • queryKey🔗 is a unique identifying array of strings used to determine cache and reload
  • queryFn🔗 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-1
await fetchUser();
await fetchPosts();
// Demo-2
await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments(),
]);
// TanStack Query
const 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 user
const { 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 projects
const { 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