Tanstack Query: Client Data Fetching, Caching and Synchronization Simplified
Overview
Here at Rushdown Studios, multiplayer games are the center of our co-dev specialization. When building a multiplayer game, many studios also need supporting tooling. This can include things like admin dashboards, moderation tooling, and more. More often that not, these tools will be built as web applications to be used by live operations folks.
As engineers, we are often tasked with building this type of supportive tooling. There are no shortage of options available to you when deciding what languages, frameworks and libraries you can use to build both the backend and frontend of these tools.
This post will be the first in a series of posts covering Tanstack. Tanstack is a suite of open source libraries for web application development. More specifically, in this first post…we’ll be talking about Tanstack Query. Tanstack Query is a framework agnostic asynchronous state management library that makes fetching, caching, synchronizing and updating server state in your web applications a breeze.
Why Tanstack Query?
When deciding on potential libraries/tooling to help you manage state in your web application, you will find that you have many options to choose from. This is driven by the fact that most web application frameworks are not opinionated about state management which leaves you with the option to try and roll your own, or choose from options such as:
- SWR
- Apollo Client - GraphQL specific
- Redux/ReduxToolKit
These are all fine options for managing complex state, and in cases where you are using a GraphQL backend, or perhaps your team has existing knowledge of Redux for example…then it absolutely makes sense to go with one of these. The benefit of Tanstack Query however, is that it isn’t tied to any specific framework or backend. Additionally, many alternatives offer most of the features of Tanstack Query…but none of them offer all of them.
Most traditional state management libraries are designed with managing only client state in mind, while synchronizing and handling server state in the client is often more ambiguous. Server state comes with it’s own unique set of challenges, for example it:
- 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 the client
The list of potential issues in dealing with server state in your client only grows as your application scales, to include considerations like:
- Caching
- Updating “stale” data in the background
- Knowing when data is out of date
- Performance optimizations like pagination and lazy loading
- And more!
The benefit of adopting Tanstack Query is that it provides solutions to all these issues out of box with near zero configuration required and is highly customizable to meet the needs of your application as it grows. The core principles that drive Tanstack’s product development provide a great overview of what makes them such a great option for your next project.
Use Case Demonstration
For the purpose of this demonstration, the code examples will use React. React is a JavaScript library for building user interfaces. To be clear, you do NOT have to use or know React to use Tanstack Query.
In a typical React application, you’ll usually see some code like this:
function App() {
return (
<ModerationDashboard />
)
}
function ModerationDashboard() {
return (
<div className="moderation-dashboard-container">
<PlayerList />
</div>
)
}
function PlayerList() {
const [players, setPlayers] = useState([]);
useEffect(() => {
fetch(`${endpoint}`)
.then(response => res.json())
.then(players => setPlayers(players))
.catch(error => console.error(error))
}, []);
function banPlayer(playerId) {
// Imaginary request to ban a player...
}
return (
<div>
<ul>
{players?.map((player) => (
<div key={player.id}>
<li>{player.name}</li>
<li>{player.status}</li>
<button onClick={() => banPlayer(player.id)}>
Ban
</button>
</div>
))}
</ul>
</div>
)
}
render(<App />, document.getElementById('root'))At a high level, this code renders a basic Dashboard component in React, and within that we render a PlayerList component that renders a list of players out with their name, status, and a button to ban them.
You may notice that there are several pretty standard things this code currently does not handle. For example, we don’t have any way to display a loading state while we wait for player data to come back from the server. Additionally, we don’t capture and display errors…let’s add that now:
function PlayerList() {
const [players, setPlayers] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState();
useEffect(() => {
// Set loading state to true when request is kicked off
setIsLoading(true);
// Make request for data and update loading/error state
fetch(`${endpoint}`)
.then(response => res.json())
.then(players => {
setPlayers(players);
// Need to manually clear error on success so we don't have a stale error
setError(undefined);
})
.catch(error => {
setError(error);
// Need to clear player data on error manually
setPlayers([]);
})
.finally(() => setIsLoading(false))
}, []);
function banPlayer(playerId) {
// Imaginary request to ban a player...
}
return (
<div>
<ul>
{players?.map((player) => (
<div key={player.id}>
<li>{player.name}</li>
<li>{player.status}</li>
<button onClick={() => banPlayer(player.id)}>
Ban
</button>
</div>
))}
</ul>
</div>
)
}
// ...restAs you can imagine, this can quickly spiral out of control, for example…what if we wanted to check things like:
- If a request is pending (no data yet)
- If a request is fetching (regardless of status)
- If a specific request is already in progress
To incorporate all this, we’d have to track additional states using useState and a bunch of conditional checks in our useEffect or multiple useEffect hooks. This quickly becomes cumbersome and hard to manage.
See below for the same implementation using Tanstack Query and all the features previously mentioned:
import {
QueryClient,
QueryClientProvider,
useQuery,
useMutation,
} from '@tanstack/react-query';
import { banPlayer } from '../api';
// Create a new Query Client
const queryClient = new QueryClient();
function App() {
return (
{/* Wrap Component tree with QueryClientProvider */}
<QueryClientProvider client={queryClient}>
<ModerationDashboard />
</QueryClientProvider>
)
};
function ModerationDashboard() {
return (
<div className="moderation-dashboard-container">
<PlayerList />
</div>
)
};
function PlayerList() {
// Tanstack Query provides a convenient hook useQueryClient to access the query
// client in wrapped components
const queryClient = useQueryClient();
// useQuery returns all the state we need automatically, no need to manually track
// this stuff in useState ourselves
const { isPending, error, players: data } = useQuery({
queryKey: ['players'],
// This fetch could be anything, a request to authenticate with some service
// Loading player data, etc.
queryFn: () =>
fetch(`${endpoint}`).then((response) =>
response.json(),
),
});
// Tanstack Query supports PUT/POST/PATCH with mutations via useMutation
const banPlayerMutation = useMutation({
mutationFn: banPlayer, // mutationFn can be defined elsewhere and imported
onSuccess: () => {
// Mark players query as stale, Tanstack Query will automatically refresh this
// query and update the UI. We want this because following a ban, a player's
// status should update
queryClient.invalidateQueries({ queryKey: ['players'] })
},
});
if (isPending) return 'Loading...';
if (error) return 'An error has occurred: ' + error.message;
return (
<div>
<ul>
{players?.map((player) => (
<div key={player.id}>
<li>{player.name}</li>
<li>{player.status}</li>
<button
onClick={() => {
banPlayerMutation.mutate({
playerId: player.id,
})
}}
>
Ban
</button>
</div>
))}
</ul>
</div>
)
}
render(<App />, document.getElementById('root'))In these few easy changes, we now get:
- A provider which makes the Query Client available to our entire Application component tree.
useQueryClientwhich allows us to access the Query client from any wrapped component.- Out of box result object returned by
useQuerythat returns a number of extremely helpful pieces of data that can be used for conditional rendering in the UI, and other logic. - Query invalidation, which is incredibly useful for notifying Tanstack Query that we need to re-fetch a query/queries and rehydrate the UI.
- This is only a small slice of everything you get out of the box as well.
Core Concepts
Queries
A query is a declarative dependency on an asynchronous source of data that is tied to a unique query key. A query can be used with any Promise based method to fetch data from a server. If your method modifies data on the server, it is recommended that you use a mutation rather than a query.
The query key is used internally to handle caching, re-fetching and accessing your query from anywhere in your application. The caching behavior in Tanstack Query is highly customizable. We will explore this in a future post, but if you want to look ahead…you can learn more about this here. There is a lot more to cover about queries in general, which you can find here.
Query Keys
TanStack Query manages query caching based on query keys. Query keys have to be an Array at the top level, and can be as simple as an Array with a single string, or as complex as an array of many strings and nested objects. As long as the query key is serializable using JSON.Stringify, and unique to the query's data…you can use it.
Query keys with variables are useful for queries that need to be more specific to the data. In this context, a good example would be to fetch a specific player profile. This might look something like the below:
// In this case, 1 would be the player ID
useQuery({ queryKey: ['playerProfile', 1], ... });You can read more about Query Keys here.
Query Functions
A Query Function can be any function that returns a promise, that promise must either resolve the data, throw an error or return as rejected.
Most commonly, the Query Function will be the function that actually makes the request for data. You can read more about Query Functions here.
Conclusion
Tanstack Query is a robust and scalable solution to state management for your next project, whether that be player facing products or things like admin tooling and moderation. In our next post covering Tanstack Query, we’ll dive into more advanced topics like pagination, query invalidation/cancellation, caching and various performance optimizations that will help you get the most out of Tanstack Query.