TLDR; Introduction to tanstack query and organizing apis with queryoptions for better maintainibility
TanStack Query is a powerful and Battle-tested library for managing asynchronous state in React applications. It provides a robust and intuitive solution for fetching, caching, and updating data from APIs.
One of the main challenges when working with asynchronous data in React is managing the different states (loading, error, success) and ensuring that the UI accurately reflects the current data state. TanStack Query simplifies this process by providing a set of hooks and utilities that abstract away much of the boilerplate code required for fetching and updating data.
At its core, TanStack Query revolves around the concept of queries and mutations. Queries are used to fetch data from a source, while mutations are used to update or modify data on the server or in the cache.
TanStack Query
Throughout this article, we'll explore the various features and capabilities of TanStack Query using a simple example of fetching and updating a list of products from an e-commerce application. Here's a quick preview of the example:
// types.ts
export interface Product {
id: string;
name: string;
price: number;
}
// api.ts
export const fetchProducts = async (): Promise<Product[]> => {
const response = await fetch('/api/products');
return response.json();
};
export const updateProduct = async (updatedProduct: Product): Promise<Product> => {
const response = await fetch(`/api/products/${updatedProduct.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedProduct),
});
return response.json();
};
We'll start by exploring the `useQuery` hook for fetching data and then move on to other features like mutations, handling loading and error states, updating cached data, and organizing our code for better maintainability.
UseQuery in Tanstack Query
The `useQuery` hook is the primary interface for fetching data with TanStack Query. It abstracts away the complexities of fetching data, handling loading and error states, and caching the fetched data for subsequent requests.
Let's start by using `useQuery` to fetch our list of products:
import { useQuery } from '@tanstack/react-query';
import { fetchProducts } from './api';
function ProductList() {
const { isLoading, error, data } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
if (isLoading || isFetching) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map((product) => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
);
}
In this example, we're using the `useQuery` hook and passing it an options object with two properties:
- `queryKey`: A unique key (or an array of keys) that identifies the query. In this case, we're using `'products'` as the key, indicating that we're fetching a list of products.
- `queryFn`: The function that will be executed to fetch the data. Here, we're passing `fetchProducts`, which is an asynchronous function that fetches the products from our API.
The `useQuery` hook returns an object with several properties, including:
- `isLoading`: A boolean indicating whether the query is currently fetching data for the first time.
- `isFetching`: A boolean indicating whether the query is currently fetching data.
- `error`: If an error occurs during the fetch operation, this property will contain the error object.
- `data`: The fetched data, or `undefined` if the query hasn't completed yet.
In our component, we're using these properties to render different states:
- If `isLoading` is `true`, we render a "Loading…" message.
- If `error` is not `null`, we render an error message with the error details.
- If `data` is available, we render a list of products by mapping over the `data` array.
When fetching data asynchronously, it's essential to provide visual feedback to the user about the current state of the operation. TanStack Query makes it easy to display loading indicators by exposing the `isLoading` and `isFetching` flags.
The `isLoading` flag indicates whether the query is currently fetching data for the first time. It's typically used to display a loading spinner or message while the initial data is being fetched.
In this example, we're rendering a "Loading…" message when `isLoading` is `true`, indicating that the initial fetch is in progress.
The `isFetching` flag, on the other hand, indicates whether the query is fetching data due to a re-render or re-fetch triggered by a change in the query key or other factors. It's useful for displaying a loading indicator when refetching data, such as after a mutation or when refreshing the cache.
By using these flags, you can provide a seamless user experience by displaying appropriate loading indicators at the right times, ensuring that your users are informed about the current state of the application.
By using `useQuery`, we've eliminated the need for managing loading and error states manually, as well as caching the fetched data. TanStack Query takes care of all these concerns under the hood, allowing us to write cleaner and more concise code.
It's worth noting that `useQuery` automatically subscribes to the query and will re-fetch the data whenever the `queryKey` or any of the values used in the `queryKey` array change. This behavior can be customized using additional options provided by the `useQuery` hook, which we'll explore later in the article.
Eliminating useEffect
In traditional React applications, developers often use the `useEffect` hook to fetch data from APIs or other sources. However, with TanStack Query's `useQuery` hook, you no longer need to manage the lifecycle of data fetching manually with `useEffect`.
Here's an example of how you might fetch data using `useEffect`:
import { useState, useEffect } from 'react';
import { fetchProducts } from './api';
function ProductList() {
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setIsLoading(true);
const data = await fetchProducts();
setProducts(data);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
);
}
In this example, we're using `useState` to manage the products, loading, and error states. Inside the `useEffect` hook, we're defining an async function `fetchData` that fetches the products data and updates the state accordingly.
While this approach works, it requires managing the component's lifecycle and state manually, which can lead to code duplication and potential bugs if not done correctly.
With TanStack Query's `useQuery` hook, you can eliminate the need for `useEffect` and manage the data fetching lifecycle automatically:
import { useQuery } from '@tanstack/react-query';
import { fetchProducts } from './api';
function ProductList() {
const { isLoading, error, data: products } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
);
}
By using `useQuery`, you no longer need to manage the loading, error, and data states manually. TanStack Query takes care of the lifecycle management and provides you with the `isLoading`, `error`, and `data` values directly.
This approach not only simplifies your code but also reduces the chances of introducing bugs related to lifecycle management. Additionally, TanStack Query provides automatic caching, refetching, and other advanced features out of the box, further reducing the need for manual state management.
Transforming Query Data
In many cases, the data received from an API or other sources may not be in the exact format required by your UI components. TanStack Query provides a convenient way to transform the fetched data using the `select` option.
The `select` option allows you to define a function that receives the raw data from the query and returns a transformed version of that data. This transformed data is then cached and passed to the components consuming the query.
Here's an example of how you can use `select` to transform product data:
// api.js
import { fetchProducts } from './api';
// ProductList.jsx
import { useQuery } from '@tanstack/react-query';
function ProductList() {
const { isLoading, error, data: products } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
select: (products) => (
products.map((product) => ({
id: product.id,
name: product.name,
formattedPrice: `$${product.price.toFixed(2)}`,
}))),
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - {product.formattedPrice}
</li>
))}
</ul>
);
}
In this example, we're using the `select` option in the `useQuery` call within the `ProductList` component.
The `select` option takes a function that receives the raw product data from the server and returns a transformed array of product objects.
Inside the `select` function, we're mapping over the raw product data and creating a new object for each product. This new object includes the `id` and `name` properties from the raw data, as well as a new `formattedPrice` property that formats the price with a dollar sign and two decimal places.
In the component, we're using the transformed data returned from the `useQuery` hook. The `data` property now contains the transformed product objects with the `formattedPrice` property, allowing us to render the formatted prices directly in the UI without additional transformation logic.
The `select` option is particularly useful when you need to perform complex data transformations, filter or sort data.
useMutation in Tanstack Query
While `useQuery` is used for fetching data, `useMutation` is the hook provided by TanStack Query for updating or modifying data on the server or in the cache. Mutations typically involve operations like creating, updating, or deleting data.
Let's explore how to use `useMutation` to update a product in our list:
// api.ts
export const updateProduct = async (updatedProduct: Product): Promise<Product> => {
const response = await fetch(`/api/products/${updatedProduct.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedProduct),
});
return response.json();
};
// ProductUpdate.jsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateProduct } from './api';
function UpdateProductForm({ product }) {
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: updateProduct,
onSuccess: (updatedProduct) => {
queryClient.invalidateQueries(['products']);
},
});
const handleSubmit = (e) => {
e.preventDefault();
const updatedPrice = e.target.elements.price.value;
const updatedProduct = { ...product, price: parseFloat(updatedPrice) };
mutate(updatedProduct);
};
return (
<form onSubmit={handleSubmit}>
<label>
Price:
<input type="number" name="price" defaultValue={product.price} />
</label>
<button type="submit">Update Price</button>
</form>
);
}
In this example, we're using the `useMutation` hook to update the price of a product. Here's a breakdown of what's happening:
- We import the `useMutation` and `useQueryClient` hooks from TanStack Query.
-
Inside the `UpdateProductForm` component, we call `useMutation` and pass an options object with the following properties:
- `mutationFn`: The function that will be executed to perform the mutation. In our case, we're passing `updateProduct`, which is an asynchronous function that updates the product on the server.
- `onSuccess`: A callback function that will be executed if the mutation is successful. In this case, we're using `queryClient.invalidateQueries(['products'])` to invalidate the cached data for the `['products']` query, ensuring that the next fetch will retrieve the updated data.
- Inside the `handleSubmit` function, we're creating a new `updatedProduct` object by spreading the existing `product` object and updating the `price` property with the value from the form input.
- We then call `mutate(updatedProduct)`, which triggers the `updateProduct` function with the updated product data.
- This `updateProduct` function takes an `updatedProduct` object of type `Product` as an argument and sends a `PUT` request to the server at the `/api/products/${updatedProduct.id}` endpoint. The updated product data is sent in the request body as JSON. The function returns a `Promise` that resolves with the updated product data returned from the server. This is the `mutationFn` that we're passing to the `useMutation` hook in the previous example.
When the mutation is successful, the `onSuccess` callback is executed, and we invalidate the `['products']` query using `queryClient.invalidateQueries(['products'])`. This ensures that the next time we fetch the product list using `useQuery`, we'll get the updated data from the server.
TanStack Query also provides other options for `useMutation`, such as `onError` (for handling errors), `onSettled` (executed after the mutation is either successful or encounters an error), and `onMutate` (for performing optimistic updates, which we'll cover in a later section).
By using `useMutation`, we can easily update data on the server and optionally update the cached data as well, without having to manually manage the state transitions or handle the complexities of updating the UI.
By separating the API calls into their own functions like `updateProduct`, we can easily reuse them across different components and keep our code organized and maintainable.
Updating Data Without Refetching
In the previous section, we saw how to use `useMutation` to update data on the server. However, after a successful mutation, we often need to update the cached data as well to ensure that subsequent queries fetch the updated data without the need for refetching.
TanStack Query provides an `onSuccess` callback in the `useMutation` hook, which allows us to update the cached data after a successful mutation. Let's revisit the `UpdateProductForm` component and enhance it to update the cached data:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateProduct } from './api';
function UpdateProductForm({ product }) {
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: updateProduct,
onSuccess: (updatedProduct) => {
queryClient.setQueryData(['products'], (oldData) => {
if (oldData) {
return oldData.map((p) =>
p.id === updatedProduct.id ? updatedProduct : p
);
}
return [updatedProduct];
});
},
});
const handleSubmit = (e) => {
e.preventDefault();
const updatedPrice = e.target.elements.price.value;
const updatedProduct = { ...product, price: parseFloat(updatedPrice) };
mutate(updatedProduct);
};
return (
<form onSubmit={handleSubmit}>
<label>
Price:
<input type="number" name="price" defaultValue={product.price} />
</label>
<button type="submit">Update Price</button>
</form>
);
}
In this updated example, we're using the `queryClient.setQueryData` method inside the `onSuccess` callback to update the cached data for the `['products']` query.
The `queryClient.setQueryData` method takes two arguments:
- `queryKey`: The key of the query whose cached data needs to be updated. In our case, it's `['products']`.
- A callback function that receives the `oldData` (the current cached data) and returns the updated data.
Inside the callback function, we're checking if `oldData` exists. If it does, we're mapping over the `oldData` array and replacing the product with the `id` matching the `updatedProduct.id` with the updated product data. If `oldData` is falsy (e.g., `undefined`), we're returning an array with the `updatedProduct` as the only element.
By updating the cached data in the `onSuccess` callback, we ensure that subsequent queries for the `['products']` key will return the updated data without the need for refetching from the server.
This approach not only improves the performance of your application by reducing unnecessary network requests but also provides a seamless user experience by instantly reflecting the updated data in the UI.
Multiple Queries in Parallel
In many applications, you may need to fetch data from multiple sources or endpoints simultaneously. TanStack Query provides a way to handle this scenario: `useQueries`.
Using useQueries
The `useQueries` hook allows you to execute multiple queries in parallel and provides a convenient way to handle the loading and error states of each query individually.
import { useQueries } from '@tanstack/react-query';
import { fetchProducts, fetchCategories } from './api';
function ProductsAndCategories() {
const [productQuery, categoriesQuery] = useQueries({
queries: [
{
queryKey: ['products'],
queryFn: fetchProducts,
},
{
queryKey: ['categories'],
queryFn: fetchCategories,
},
],
});
const { isLoading: isProductsLoading, data: products } = productQuery;
const { isLoading: isCategoriesLoading, data: categories } = categoriesQuery;
// Render loading state or data based on the query results
}
In this example, we're using `useQueries` to fetch both products and categories simultaneously. The hook takes an object with a `queries` property, which is an array of query option objects. Each object in the array represents a separate query, with its own `queryKey` and `queryFn`.
The `useQueries` hook returns an array of query results, where each result corresponds to the respective query in the `queries` array. We're destructuring these results into `productQuery` and `categoriesQuery`.
We can then access the `isLoading` and `data` properties from each query result and render the appropriate UI based on the loading state and fetched data.
`useQueries` provide flexibility in fetching multiple pieces of data in parallel, allowing you to optimize your application's performance and user experience.
Dependent Queries
In many applications, you may encounter scenarios where you need to fetch data that depends on the result of another query. TanStack Query provides a powerful mechanism to handle these dependent queries seamlessly.
Imagine you have a product listing page where you first fetch a list of categories, and then based on the selected category, you fetch the corresponding products. Instead of fetching all products and filtering them on the client-side, you can use dependent queries to fetch only the relevant data.
Here's an example of how to implement dependent queries with TanStack Query using in-place queries:
// api.js
import { fetchCategories, fetchProductsByCategory } from './api';
// ProductsPage.jsx
import { useQuery } from '@tanstack/react-query';
function ProductsPage() {
const { isLoading: isCategoriesLoading, data: categories } = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
});
const categoryId = categories?.[0]?.id; // Assuming the first category is selected by default
const { isLoading: isProductsLoading, error, data: products } = useQuery({
queryKey: ['products', categoryId],
queryFn: () => fetchProductsByCategory(categoryId),
enabled: !!categoryId, // Only enable the query if a category is selected
});
// Render categories, products, loading/error states, etc.
}
In this example, we're using `useQuery` within the `ProductsPage` component to fetch both categories and products.
First, we define a query to fetch the list of categories using the `['categories']` query key and the `fetchCategories` function as the `queryFn`. This query will execute immediately upon component render.
Next, we destructure the `categoryId` from the first category in the `categories` array (assuming the first category is selected by default). This `categoryId` will be used to trigger the products fetch.
Then, we define another `useQuery` to fetch the products based on the selected `categoryId`. The `queryKey` is an array containing `['products', categoryId]`, ensuring that a separate query is created for each unique category. The `queryFn` is an inline function that calls `fetchProductsByCategory` with the `categoryId` argument.
Importantly, we set the `enabled` option to `!!categoryId`, ensuring that the products query is only executed when a category is available.
When the `['categories']` query completes and the `categories` data is available, the `categoryId` from the first category is used to fetch the corresponding products by triggering the `['products', categoryId]` query.
By using dependent queries, you can efficiently fetch and cache data based on the results of other queries, without redundant fetches or client-side filtering.
Dependent queries are particularly useful in scenarios where you need to fetch data based on user interactions or other conditions.
QueryOptions and Code Organization
As your application grows in complexity, it becomes increasingly important to maintain a well-organized codebase. TanStack Query's queryOptions provide a way to encapsulate and separate the logic for fetching data from the UI components that consume the data.
queryOptions are the set of options passed to the `useQuery` hook, such as `queryKey`, `queryFn`, `enabled`, `refetchOnWindowFocus`, and many others. By extracting these options into separate functions or constants, you can decouple the data fetching concerns from your UI components, making your code more modular and easier to maintain.
Here's an example of how you can organize your code using Query Options:
// queries.js
import { fetchProductsByCategory } from './api';
import { queryOptions } from '@tanstack/react-query'
export const getProductsQuery = (categoryId = null) => {
return queryOptions({
queryKey: categoryId ? ['products', categoryId] : ['products'],
queryFn: fetchProductsByCategory(categoryId),
refetchOnWindowFocus: false,
staleTime: 60000, // Cache products for 1 minute
})
};
// ProductList.jsx
import { useQuery } from '@tanstack/react-query';
import { getProductsQuery } from './queries';
function ProductList({ categoryId }) {
const { isLoading, error, data: products } = useQuery(getProductsQuery(categoryId));
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
)}
</ul>
);
}
In this example, we've created a separate `queries.js` file where we define a `getProductsQuery` function that returns the queryOptions object for fetching products. `getProductsQuery` function accepts an optional `categoryId` parameter. The `queryKey` is constructed based on whether `categoryId` is provided or not. If `categoryId` is not provided, the query will fetch all products. If `categoryId` is provided, the query will fetch only the products belonging to that category. The `queryFn` calls `fetchProductsByCategory` with the `categoryId` argument. This allows us to reuse the same Query Options function for fetching all products or products filtered by a specific category.
This function encapsulates all the options related to fetching products, such as the `queryKey`, `queryFn`, `refetchOnWindowFocus`, and `staleTime`.
In the `ProductList` component, we're importing the `getProductsQuery` function and passing it directly to the `useQuery` hook. This separation of concerns makes it easier to maintain and modify the Query Options independently from the UI component.
By organizing your code in this way, you can:
- Reuse Query: You can easily reuse the same Query across multiple components by importing and calling the corresponding function.
- Centralize API Calls: All your API calls or data fetching logic can be centralized in a single file or module, making it easier to manage and update.
- Separate Concerns: UI components are responsible for rendering data, while Query Options handle the data fetching and caching concerns.
- Testability: With the data fetching logic separated from the UI components, it becomes easier to unit test each part of your codebase independently.
As your application grows, following this pattern of separating Query Options from UI components can greatly improve the maintainability and scalability of your codebase.
Avoiding UI updates for non-dependent parameters
Imagine that you want to fetch the data only on button click and not on other states of your UI, however fetching data also requires other states as well. You can do this by passing multiple arguments to queryOptions but making your queryKey dependent only on arguments that you want to trigger fetch.
In previous example lets imagine a scenario where the query function (`fetchProductsByCategory`) requires multiple arguments, but the query should only depend on a subset of those arguments.
// queries.js
import { fetchProductsByCategory } from './api';
export const getProductsQuery = (categoryId = null, options = {}) => {
return queryOptions({
queryKey: categoryId ? ['products', categoryId] : ['products'],
queryFn: async () => {
const { sortBy, searchTerm } = options;
return fetchProductsByCategory(categoryId, sortBy, searchTerm);
},
refetchOnWindowFocus: false,
staleTime: 60000,
})
};
// ProductList.jsx
import { useQuery } from '@tanstack/react-query';
import { getProductsQuery } from './queries';
function ProductList({ categoryId, sortBy, searchTerm }) {
const options = { sortBy, searchTerm };
const { isLoading, error, data: products } = useQuery(getProductsQuery(categoryId, options));
// ... (rendering logic)
}
In this updated example, the `getProductsQuery` function now accepts a second `options` argument, which is an object containing `sortBy` and `searchTerm` properties. The `queryFn` is an async function that destructures these properties from the `options` object and passes them to the `fetchProductsByCategory` function along with the `categoryId`.
However, the `queryKey` still only depends on the `categoryId`, ensuring that the query is not invalidated when `sortBy` or `searchTerm` changes.
In the `ProductList` component, we're creating an `options` object with the `sortBy` and `searchTerm` values and passing it to the `getProductsQuery` function along with the `categoryId`.
By separating the arguments required for the query function from the arguments required for the `queryKey`, you can ensure that your queries are only invalidated when necessary, improving performance and reducing unnecessary refetches.
Mutation Options and Code Organization
While Query Options provide a great way to organize and encapsulate the logic for fetching data, they cannot be used for mutations.
However, TanStack Query provides a way to organize and reuse mutation logic by sending objects to the `useMutation` hook. This approach allows you to encapsulate the mutation logic in a separate function or module, similar to how we organized the queryOptions in the previous section.
Let's revisit the `UpdateProductForm` component and refactor it to use an object instead of directly passing the mutation function to `useMutation`:
// mutations.js
import { updateProduct } from './api';
export function updateProductMutation() {
return {
mutationFn: updateProduct,
onSuccess: (updatedProduct, _variables, queryClient) => {
queryClient.setQueryData(['products'], (oldData) => {
if (oldData) {
return oldData.map((p) =>
p.id === updatedProduct.id ? updatedProduct : p
);
}
return [updatedProduct];
});
},
}
};
// UpdateProductForm.jsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateProductMutation } from './mutations';
function UpdateProductForm({ product }) {
const queryClient = useQueryClient();
const { mutate } = useMutation(updateProductMutation());
const handleSubmit = (e) => {
e.preventDefault();
const updatedPrice = e.target.elements.price.value;
const updatedProduct = { ...product, price: parseFloat(updatedPrice) };
mutate(updatedProduct);
};
return (
<form onSubmit={handleSubmit}>
<label>
Price:
<input type="number" name="price" defaultValue={product.price} />
</label>
<button type="submit">Update Price</button>
</form>
);
}
In this example, we've created a new file `mutations.js` where we define an `updateProductMutation` function. This function returns object that encapsulates the `mutationFn` (the `updateProduct` function) and the `onSuccess` callback that updates the cached data for the `['products']` query.
In the `UpdateProductForm` component, instead of passing the mutation options directly to `useMutation`, we're importing the `updateProductMutation` function and passing it to `useMutation`. This allows us to reuse the mutation logic across multiple components and keep our code organized.
By sending objects to `useMutation`, you can:
- Reuse Mutation Logic: Similar to queryOptions, you can encapsulate and reuse mutation logic across multiple components by defining it in a separate module or function.
- Centralize API Calls: All your API calls or mutation functions can be centralized in a single file or module, making it easier to manage and update.
- Separate Concerns: UI components are responsible for handling user interactions and triggering mutations, while the mutation objects handle the mutation logic and side effects.
- Testability: By separating the mutation logic from the UI components, you can more easily unit test the mutation functions and side effects independently.
This approach not only improves code organization and maintainability but also promotes code reuse and separation of concerns, making your codebase more scalable and easier to work with as your application grows in complexity.
Conclusion
TanStack Query is a powerful and feature-rich library that simplifies the process of managing asynchronous state in React applications. By providing hooks like `useQuery` and `useMutation`, along with advanced features like queryOptions, parallel queries, and caching mechanisms, TanStack Query streamlines data fetching, updating, and caching. Throughout this article, we explored various aspects of TanStack Query, including fetching data with `useQuery`, updating data with `useMutation`, handling loading and error states, updating cached data without refetching, organizing code with query options, and more.