React Query tutorial with react-native & reactJS example
Getting Started with React Query
Learn how to use react query from scratch. We’ll build a ReactJS app as well as an app in react-native.
React Query will make your life easy with fetching and manipulating data from server.
React Query is a library for fetching and managing data in React applications. It provides a way to manage data that is fetched from APIs, including caching, pagination, and the ability to refetch data based on certain conditions.
As most of the apps has huge dependency on fetching data from APIs and rendering changes to front end accordingly, that’s one of the reasons why react query is becoming popular and is a choice for most of the people rather than using redux.
Prerequisite:
Understanding of React or React-Native is a must. I’ve used typescript so if you don’t know TS just ignore the typings used and you’ll be GTG, but typescript is also not a prerequisite.
Anything in this article that you see is in italics it’ll means it’s optional/extra information.
To read this article for free: https://ibjects.medium.com/af2951f1f7c3?source=friends_link&sk=2f005c2b6fd41cc7cd62bf4101fdcd09
Creating react project & installing react query
Create a new react project. You can use any IDE, for installation part I’m using IDX which is basically VSCode but online.
Below is how my newly created vanilla* ReactJS project looks like.
*vanilla: If you go to the latest docs https://react.dev/learn/start-a-new-react-project it’ll encourage you to create a NextJS project instead which is still okay as it’ll work fine with the concepts covered for react-query. For the most part doing npx create-react-app rq-tutorial
would work fine.
Install react-query in your project by running yarn add @tanstack/react-query
from terminal.
Create a new file named Home.tsx
and open App.tsx
file. Remove everything and make sure both files have the following codes as follows:
This is just cleaning up the project and now we’re ready to start implementing the react-query magic.
Getting Started with React Query
To get started with React Query, we’ll first need to wrap our entire App inside React Query.
(It’s like how we used to wrap our app in Provider
with a store prop <Provider store={store}> in redux.)
Open main.tsx
(or App.tsx
or index.js
) and add this:
//... other imports
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
)
We wrapped our entire App
component with a QueryClientProvider
and passed it a QueryClient
. This is basically telling React that we’re going to use react query. We can add more properties provided by react-query like cache time and other if required here that’ll be applied to our entire app but that’s for later.
Example 1: Basic CRUD App (ReactJS)
I’ll first show you a GET call, which will help you grasp the simple concept of react-query. Then we’ll have a CRUD application where we’ll build products CRUD i.e. to add, fetch, update, and delete products using react-query.
Basic Example
Let’s take a basic example where we’ll just GET some data from API: https://jsonplaceholder.typicode.com/posts
I have created a file named PostsFetchExample.tsx
and added it to render in App.tsx
as:
In PostsFetchExample
we’ll fetch the posts. So add the following code:
import { FC } from "react";
import { useQuery } from '@tanstack/react-query';
import '../App.css'
interface PostsFetchExampleProps {
title: string
};
interface Post {
"userId": number,
"id": number,
"title": string,
"body": string
};
const PostsFetchExample: FC<PostsFetchExampleProps> = ({ title }: PostsFetchExampleProps) => {
const { data, isError, isLoading } = useQuery({
queryKey: ['allPosts'],
queryFn: () => fetch('https://jsonplaceholder.typicode.com/posts').then((res) => {
if (!res.ok) {
throw new Error(`Network response was not ok, status ${res.status}`);
}
return res.json();
}),
});
if (isLoading) {
return <div>Loading...</div>;
}
if(isError) {
return (
<div>
<h1>Error Occured</h1>
<p>Please try again later</p>
</div>
)}
return (
<div>
<h1>{title}</h1>
{data.map((post: Post) => <div>
<p>{post.title}</p>
</div>)}
</div>
);
}
export default PostsFetchExample;
We have imported useQuery
hook from React Query. It takes basic two arguments i.e., queryKey
which is a unique key that’ll be unique for this API call. Next is a queryFn
which takes a function and returns a promise. In this case, we have used fetch
to get the posts and then converted them res
into json
.
useQuery
provides us with things like isLoading
for when the data is loading, isError
in case there was an error with fetching data i.e. queryFn
. The data
that will contain our response when returned i.e. res
.
Then we just rendered the UI accordingly and in this case.
This is the very basic of what RQ will do, it’ll provide out-of-the-box different states to you and you can see how easy it is to manage the UI in just a few lines.
If you say change jsonplaceholder to jsonplacehold in the API URL you can see it’ll render the isError
UI instead and briefly it’ll also show Loading… when it’s loading the data.
Lets make a small modification here by adding a retry button in the error UI, and also change the API URL to wrong one like https://jspn.typicode.com/posts
then modifying the error UI to have a <button
:
if(isError) {
return (
<div>
<h1>Error Occured</h1>
<p>Please retry again</p>
<button>
Retry
</button>
</div>
)}
Doing the above will fail and hence our project will render this instead:
Now to code the Retry
button onClick
we’ll first need to include refetch
where we had useQuery
.
const { data, isError, isLoading, refetch } = useQuery({
// Everything else is the same just refetch is added
React Query provides a lot of things out-of-the-box, it depends on what you are going to need. Let’s finish this example, modify the <button>
code to have an onClick
:
//... rest of the code
<button onClick={() => refetch()}>
Retry
</button>
//... rest of the code
Now if you press the Retry button it’ll try again to fetch and as the URL is invalid still so you’ll keep seeing the error UI. If you modify the URL to correct one i.e. jsonplaceholder
instead of jspn
and press Retry button, it’ll show the posts.
You can see how it’s very simple to use react-query.
CRUD of Example 1
Let’s consider a new example. I’ve create a REST API that has the following end-points:
If you don’t know what cache is, read below:
React Query uses cache to store data, but what is a cache? A cache is a memory where data is stored temporarily so your app can quickly access it again without needing to re-fetch or re-calculate it.
React-Query provides us ways to access this cache, invalidate the cache to fetch the latest data and other functionalities which makes our app fast and our state reliable with latest data.
Remember the goal is to keep the front-end of the app reflect the state of the server means whatever data is on the server the FE should refresh and loads it as quickly as it can and this is where React-Query does a great job.
We’ll implement each of these using react-query and we’ll build a complete one-pager ReactJS application.
Optional: UI Implementation
In this groundwork, I’m going to create files needed to implement UI, typings, and other utility files that are going to be needed, and will not do anything related to react-query. So, this is optional for you to follow this.
Let’s define product type first. So in my src
folder I have added a folder named typings
and inside I created an index.d.ts
file, in which I’m adding the following code:
declare namespace ProductsCatalog {
type Product = {
id: string;
price: number;
name: string;
};
type PartialProduct = Partial<Product>;
}
The Product will have id
, price
, and name
and then in case, I need to update/edit a product I’ll be passing say only name
or price
hence created PartialProduct
type.
To set up the UI, I’ll need a form. I have added a new page named Products.tsx
in my project:
Add this boilerplate code to Product.tsx
import { FC, useState } from "react";
import '../App.css'
interface ProductsProps {
title: string
};
const Products: FC<ProductsProps> = ({ title }: ProductsProps) => {
return (
<div>
<h2>{title}</h2>
</div>
);
}
export default Products;
I’ll add stylings to App.css
that is why I have imported it here. I have also loaded the Product
screen in my App.tsx
so now I can see the Products
heading in output.
Let’s add a form in the Products screen now. This form will be useful for adding new products, and editing/updating an existing product.
const priceInput = useId();
const [formData, setFormData] = useState({ productName: '', price: '' });
const renderForm = () => {
return (
<div className="productForm">
<form method="post" onSubmit={handleSubmit}>
<label htmlFor="name">Product Name
<br /><input
type="text" className="textField"
id="name"
name="productName"
value={formData.productName}
onChange={handleChange} />
</label>
<br />
<label htmlFor={priceInput}>Price
<br />
<input id={priceInput} className="textField"
name="price"
type="number"
value={formData.price}
onChange={handleChange}
min={0} />
</label>
<br />
<button type="submit">
{formCTATitle}
</button>
</form>
</div>
);
};
Next add the implementation of handleChange
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prevFormData => ({
...prevFormData,
[name]: name === 'price' ? parseFloat(value) || 0 : value
}));
};
The formData
is holding the characters that are being typed in the input
field of the form above.
Now for handleSubmit
lets add this unfinished function:
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
// Prevent the browser from reloading the page
e.preventDefault();
setError(''); // Clearing any previous errors
const form = e.currentTarget as HTMLFormElement;
const newFormData = new FormData(form);
const formJson = Object.fromEntries(newFormData.entries()) as { productName: string, price: string };
// Check if productName is entered and is not just an empty string
if (!formJson.productName || formJson.productName.trim() === '') {
setError('Please enter a product name.');
return; // Stop further execution if validation fails
}
// Uncomment these to see in terminal, if you're getting proper values
// console.log("Product Name: ", formJson.productName);
// console.log("Product Price: ", formJson.price);
// Reset the form to initial values
form.reset();
};
The above code simply reads the values the user has entered and right now we’re just printing them in console as we’ll get back to this where we’ll use formJson.productName
and formJson.price
value and POST to our API call.
Next in the main return
of the Products
add the code below after the <h2>{title}</h2>
which we added earlier in this optional section.
{error && <p style={{ color: 'red' }}>{error}</p>}
{renderForm()}
<pre>Here is a list of all products that are added in the database</pre>
<hr />
This should render the form like this.
Don’t have the bluish background on the form as I have? This is because I have added this to my App.css
file:
.productForm {
width: auto;
background-color: rgb(159, 237, 237);
padding: 2rem;
border-radius: 12px;
}
.textField {
width: auto;
padding: 8px;
border-radius: 8px;
border-width: 1px;
border-style: solid;
margin-bottom: 8px;
}
You can style the form as you like.
The next thing we need is a Product Card. That’ll display the product name, price, and buttons to edit and delete. I have a components folder, in it add a file named ProductCell.tsx
and in it add the following code:
import { FC } from "react";
interface ProductCellProps {
productData: ProductsCatalog.Product;
onEditPressed?: () => void;
onDeletePressed?: () => void;
};
export const ProductCell: FC<ProductCellProps> = ({ productData, onEditPressed, onDeletePressed }: ProductCellProps) => {
return (
<div className="card">
<p>{productData.name ?? ''} - ${productData.price.toString()}</p>
<hr />
<span>
<a onClick={onEditPressed}> Edit </a>
-
<a onClick={onDeletePressed} className="deleteLink"> Delete </a>
</span>
</div>
);
}
Where className="card"
in App.css
is:
.card {
padding: 8px;
border-width: 1px;
border-style: solid;
border-radius: 10px;
margin-top: 1rem;
}
.deleteLink {
color: red;
}
Adding <ProductCell...
in Products.tsx
just to see how it’s looking.
I’ve added a const sampleProduct
just to showcase the UI of the ProductCell
. This is a very simple UI that we are creating here. For the handleEditProduct
and handleDeleteProduct
just create these empty functions:
const handleDeleteProduct = (productId: string) => {
// Coming Soon
};
const handleEditProduct = (product: ProductsCatalog.Product) => {
// Coming Soon
};
That’s pretty much it for the UI part. Next set of changes we’ll make as we implement our APIs.
Implementing React-Query for all our CRUD API calls
Create a utils
folder in the src
folder and add two files named API.ts
and FetchingProducts.ts
in it:
We’ll have our API calls in the API.ts
file and all the related code which will be related to fetching data in the FetchingProducts.ts
file.
I’m using axios, but you can use fetch or any other library. In API.ts
file add this code:
import axios from 'axios';
const api = axios.create({
baseURL: 'https://YOUR_BASE_API_URL_HERE/api',
});
/**
* POST: Create a product
* GET: Retrieve all products
* GET: Retrieve one product
* PUT: Update a product
* PATCH: Partial update a product
* DELETE: Delete a product
*/
export const fetchProducts = async () => {
const { data } = await api.get('/products');
return data;
};
export const fetchProductById = async (id: ProductsCatalog.Product['id']) => {
const { data } = await api.get(`/products/${id}`);
return data;
};
export const createProduct = async (product: Omit<ProductsCatalog.Product, 'id'>) => {
const { data } = await api.post('/products', product);
return data;
};
export const updateProduct = async (id: ProductsCatalog.Product['id'], product: ProductsCatalog.Product) => {
const { data } = await api.put(`/products/${id}`, product);
return data;
};
export const patchProduct = async (id: ProductsCatalog.Product['id'], partialProduct: ProductsCatalog.PartialProduct) => {
const { data } = await api.patch(`/products/${id}`, partialProduct);
return data;
};
export const deleteProduct = async (id: ProductsCatalog.Product['id']) => {
const { data } = await api.delete(`/products/${id}`);
return data;
};
As shared at the start of this section, I’ve implemented all the APIs.
Using the same approach which we used in our Basic Example let’s implement the GET call that’ll be fetching all the products using react-query.
Open FetchingProducts.ts
and in it add this:
import { useQuery } from '@tanstack/react-query';
import { fetchProducts } from './API';
export const useProducts = () => {
return useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
};
Now open Products.tsx
and let’s call this custom hook there and render the data.
//... other imports
import { useProducts } from '../utils/FetchingProducts';
const Products: FC<ProductsProps> = ({ title }: ProductsProps) => {
const { data, isLoading, isError, error: apiError } = useProducts();
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return (
<div>
<h1>Error Occured</h1>
<p>Please try again later</p>
<p>Error Message</p>
<p>{apiError.message}</p>
<pre>As I am using beeceptor.com to create this mock API, so if the error code is 429 this means the API has hit {'\n'}it's limit and you need to change the URL or wait for 24 hours. {'\n'}The API used has 50 request per day limit.</pre>
</div>
)
}
const renderData = () => {
if(Array.isArray(data)) {
if(data.length > 0) {
return data.map((product: ProductsCatalog.Product) => <ProductCell
key={product.id}
productData={product}
onEditPressed={() => handleEditProduct(product)}
onDeletePressed={() => handleDeleteProduct(product.id)}
/>)
} else {
return <div>No data is added. Please add some products to be displayed here.</div>
}
} else {
/**
* This means this API has returned a string
* I've just handled it accordingly to what beeceptor API is doing in this case
*/
return <>
<div>{data}</div>
</>
}
}
//... all other code and the code below is after the main return (...'s <hr />
{renderData()}
//... all other remaining code
}
It’ll depend on your back-end how you are handling the API calls, meaning if your back-end cache results or not. Using react-query means we manage the API calls from front-end better.
In the code above, we are importing our useProducts
hook and decoupling data
, isLoading
, isError
, and error message as apiError
. Then returning the UI accordingly to loading or error state. The mock API site I’m using has 50 calls per day limit so I have added the error message accordingly as the back-end it provides is like this.
Then I created a function named renderData
that is responsible to render the data
that is being fetched. Again as per how the back-end is returning the data, I first check if it has returned an array or not. Because it will return an array or a string. Then according to the returned data, I am returning the UI. Then lastly I call {renderData()}
from the main component return (...
.
As there is no data right now, so my back-end will return a string which I’m rendering as it is in div
in else
so below is how the output will look like now:
So far nothing new, as to what we did with our basic example before.
Now if I check the back-end logs, I can see that the react-query is calling the endpoint multiple times within zero seconds time.
As react-query goal is to keep the data at your client side as fresh as it can as per the server state, in that case, it’s doing a good job above and it’ll depend on your use-case if your back-end decides to cache data or not.
The reason for using beeceptor mock API for this example was to demonstrate the concept of front-end caching, as it makes sense in this case because there is a limit of 50 API calls per day which can run out very easily if I keep hitting the end-point. The time that the products data that we fetched from the API just now, is considered fresh for 30 mins time. Which is just an assumption and should be verified on use-case basis. This will mean for the next 30 mins just return the front-end cached result, instead of calling the API again and again. How will I do that?
staleTime
is a property we can add to our [‘products’]
query. So add it as show below in FetchingProducts.ts
I recommend reading https://dev.to/thechaudhrysab/simple-understanding-of-gctime-staletime-in-react-query-35be at this point to get an idea of what
staleTime
andgcTime
is. I haven’t addedgcTime
in this example, but it is below in the react-native so it’ll be covered soon.
Now, if I refresh and check the log it works as it’s retaining data for 30 mins:
Let’s get into POST API now, which will add a product. We’ll use a hook by react-query called useMutation. This hook is indeed used for creating, updating, or deleting data. Used for any server-side changes which might change the state of the server.
Create a new file in utils
folder named MutatingProducts.ts
and in it add:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createProduct } from './API';
export const useCreateProduct = () => {
const queryClient = useQueryClient();
return useMutation({
// No need to mutationKey as there is no need for caching it
mutationFn: (product: Omit<ProductsCatalog.Product, 'id'>) => createProduct(product),
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['products']})
},
networkMode: 'online',
retry: 1,
});
}
The useMutation
hook takes a mutationFn
prop: function to which we can pass product
which we pass to createProduct
API earlier. Then in onSuccess
we are invalidation the ['products']
which means that if now we call useProducts
it’ll recall the API and fetch the latest data. By default, React-query will not retry a mutation on error. If mutations fail because the device is offline, they will be retried. You can also add an additional prop networkMode: 'online',
which will indicate that only the retry of the client is connected to the internet.
Let’s post some data to our server. For that, we’ll have to edit handleSubmit
function.
import { useCreateProduct } from "../utils/MutatingProducts";
const Products: FC<ProductsProps> = ({ title }: ProductsProps) => {
//... Rest of the code
const { mutate: creatProductMutate, error: createProductError } = useCreateProduct();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
//... other code until if (!formJson.productName... }
// Add new product
creatProductMutate({
name: formJson.productName,
price: parseFloat(formJson.price)
})
//... other remaining code
};
Let’s add data data from the form and you can see it automatically fetches as I add new data. So, now I have added these two products which reflect perfectly on the app.
Next, we’ll implement Delete. Open MutatingProducts.ts
and add this new function:
import { createProduct, deleteProduct } from './API';
export const useDeleteProduct = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteProduct(`${id}`),
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['products']})
},
networkMode: 'online',
retry: 1,
});
}
Same as before, it calls the deleteProduct
API instead.
Next in Products.tsx
change handleDeleteProduct
to:
const handleDeleteProduct = (productId: string) => {
deleteProductMutate(productId);
};
This is already attached with onDeletePressed
of <ProductCell…
if you try to delete Guitar, it’ll update the UI to:
Next, to keep things simple, I’m only going to implement updateProduct
only, and you can implement the patchProduct
yourself (About Patch vs PUT). Basically we’re now going to allow users to Edit the product. It has the following steps:
- When the update product is pressed, it should update it in the database and fetch the latest data
- When Edit is pressed, show the data in the form above and change the CTA title to Update Product
- Allow user to cancel editing product
For the first step, we’ll do the same as what we did for delete and create, we add the useMutation
for updateProduct
API in MutatingProducts.ts
:
import { updateProduct } from './API';
export const useUpdateProduct = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (product: ProductsCatalog.Product) => updateProduct(product.id, product),
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['products']})
},
networkMode: 'online',
retry: 1,
});
For step 2, open Products.tsx
and add the following code:
//... other imports
import { useCreateProduct } from "../utils/MutatingProducts";
const Products: FC<ProductsProps> = ({ title }: ProductsProps) => {
//... Rest of the code
const { mutate: updateProductMutate, error: updateProductError } = useUpdateProduct();
const [editingProduct, setEditingProduct] = useState<ProductsCatalog.Product | null>(null);
const [formCTATitle, setFormCTATitle] = useState("Add Product");
const handleEditProduct = (product: ProductsCatalog.Product) => {
setEditingProduct(product);
setFormCTATitle("Update Product");
setFormData({ productName: product.name, price: product.price.toString() });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
//... other code until if (!formJson.productName... }
if (editingProduct) {
try {
updateProductMutate({
id: editingProduct.id,
name: formJson.productName,
price: parseFloat(formJson.price)
});
setEditingProduct(null); // Clear editing mode
} catch (error) {
// Handle update errors gracefully
setError('Error updating product: ' + editingProduct.name);
}
} else {
//... Now the creatProductMutate will be inside this else
creatProductMutate({
name: formJson.productName,
price: parseFloat(formJson.price)
})
}
// Reset the form to initial values
form.reset();
// Reset form after submit
setFormData({ productName: '', price: '' });
setFormCTATitle("Add Product");
setEditingProduct(null);
};
First the handleEditProduct
will set editingProduct
to the one we just pressed Edit button on. Then it sets the CTA title to Update Product
and then we update the form data so that we can see it in the text fields.
Then extending on our handleSubmit
, we added an if
condition that checks if the submit pressed is editingProduct
or not (i.e. adding new product). If it is editing product then we have called updateProductMutate
just as we did in creating. Added all of this in try-catch as in case there is an error. At the end, we are setting everything back to what it was.
We have finished basic CRUD where we are fetching all the products. Adding new products, deleting products, and updating them.
🏆 Challenge: Using the knowledge you have just obtained, extend this example where products have
description
and upon clicking a product we’ll show the description. This will require implementingfetchProductById
API.
Up to this point, you can find all the code in the repo link below:
Example 2: Anime Catalog App (React-Native):
Let’s step up a bit, from what we have learned so far. You have successfully did CRUD operation using react-query, but there is much more that it provides. In this example we’ll use same concepts but with some additional features like infinite scrolling (pagination) fetching single product data, persisting data in react-query etc. We’ll create a react-native app for this example.
Objective: We’ll create an Anime Catalog app with the necessary functionality to list & view anime and their details and also to add/remove to your favorite Anime.
API: https://docs.api.jikan.moe/#tag/anime
Pre-Setup:
TLDR; You can choose how you want to set up the UI. You can check the API documentation to understand what ie being returned. But mandatory libraries are "@tanstack/react-query": "5.32.0"
and "@react-native-async-storage/async-storage": "1.23.1"
which will be used to store favourite in the local storage of the phone.
We’ll go through quickly on creating a new project and creating the basic UI of the app.
Create a new react-native (v0.74.0) project, named AnimeCatalog
I added different stack, tab, and some other navigations, @tanstack/react-query
, and some other libraries mainly the ones below:
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/drawer": "^6.6.15",
"@react-navigation/native": "^6.1.17",
"@react-navigation/stack": "^6.3.29",
Before starting here is the project repo, to match the package.json
versions.
If you want to see the app in action you can download this APK: https://github.com/ibjects/AnimeCatalog/releases/tag/v1.0 or view the video in the github repo.
In the src
folder, I have the project arranged like below:
There will be three main screens in this app:
Home.tsx
This will be an infinite scroll screen where we’ll show the list of Anime based on the tab item of the tab bar user is on.Favourites.tsx
This screen will show all the ❤️ anime that the user did.Details.tsx
FromHome
orFavorites
user can navigate to a details screen.
Optional UI: You can design your UI as you like. After checking what type of data I should be expecting from API response, I designed the basic UI of the app as below, in which on Home (Anime Listing) page we have implemented the tab bar navigation that displays Airing, Upcoming, and Complete anime lists. The in components
folder I’ve created a AnimeItem.tsx
which is how an anime is shown below in rounded rectangle. It’ll be the same on Favorite
screen, but when no data is available it’ll display the message in blue. I also added the design of how my Details
screen will look like.
There is a drawer navigation that’ll take user to favorites screen and back. You can see all the code for the design in src
folder in the github repo.
The Drawer navigation has custom components like header and button, which are in components
folder. I have the below two files in utils
folder as well where one is the constants storing things like enum Status
which is what my bottom tab has and colors.
Getting Started with react-query in react-native app
Just like in our Example 1, we’ll first need to wrap our entire App inside React Query. So open App.tsx
and add:
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function App(): React.JSX.Element {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<MainDrawer />
</NavigationContainer>
</QueryClientProvider>
);
}
Here I want to do one more thing. I want to add garbage collection time (gcTime). By setting gcTime
to a specific duration (in milliseconds), you control how long data stays in the cache after it is no longer actively used. For my app, 30 mins for garbage collection seems reasonable.
Modify the const queryClient = new QueryClient(…
to:
const MINUTE = 1000 * 60;
const queryClient = new QueryClient({
defaultOptions: {
// The options here will apply to all queries in QueryClientProvider client={queryClient}.
queries: {
gcTime: 30 * MINUTE, // garbage collection time.
},
},
});
With gcTime
I have specified 30 minutes for the data to stay in the cache after it is no longer being actively used.
You can explore and see what more options that can be useful for your use-case can be utilized here, even I didn’t needed gcTime
but I just added to demonstrate how you can set global properties to your query client.
Home Screen
Let’s set everything for our Home.tsx
.
You have already seen a bit of the UI above, here is a bit more info about the screen. Below are three screenshots, one shows when the anime data is populated, the other shows when there is no data and the last one shows when the data is loading. Recall example 1, where we used the states provided by react-query i.e. isLoading
, data
, isError
etc, it’s the same that we’ll be implementing here soon.
Another thing is the three bottom-tabs i.e. Airing, Upcoming, Complete which are the same as enum Status
I shared above, which is just three different categories that the API end-point requires:
- https://api.jikan.moe/v4/anime?q=&status=airing&page=1
To fetch all Airing data - https://api.jikan.moe/v4/anime?q=&status=complete&page=1
To fetch all Complete data - https://api.jikan.moe/v4/seasons/upcoming?page=1
To fetch all Upcoming data
As user changes tab we’ll need to call the above API’s accordingly. There are two important things to notice in the above URLs
- Notice that for airing and complete the URL is very similar but it’s a bit different for upcoming.
- Each has a
page
param in the URL which means there is pagination involved.
In my bottom-tab navigation, I’m passing the home screen with the Status
value like <Home status={Status.Airing} />
etc, as shown below:
You can pass this value in any way you have your UI structured.
Now to implement the three APIs using react-query I can make three useQuery
calls, BUT that can be avoided as I can just pass in the status
value which I’m getting when user is navigating to different tabs, and then calls the URL in one useQuery
, and give the queryKey
as the status
value, that’ll work 100%. But there is pagination involved (page=1), which means not all the data will be sent at once. So, we cannot use useQuery
as it is essential to fetch once (unless there is a need to fetch again) kinda situations.
This is the classic case of infinite scroll, which you have seen in apps like facebook, instagram or other infinite scroll timeline kinda apps. If you open any of the three API URLs and check the response you’ll see there is a pagination
object returned, which we’ll use here to determine if there is more data to fetch or not. Please note that it’ll depend on how your back-end is handling pagination but in any case you can use the same react-query hook which we’ll see next, in almost all the cases that I have encountered for infinite scroll.
We’ll be using useInfiniteQuery
hook provided by react-query in this case. The options for useInfiniteQuery
are identical to the useQuery
hook we used before but with additional support for pagination.
Create a new file named useFetchAnimeListing.ts
in utils
folder and add the boiler-plate code as below:
import { BaseURL, Status } from '../utils/constants';
const useFetchAnimeListing = (status: Status) => {
var urlParameters = `anime?q=&status=${status.toLowerCase()}&page=`;
if (status === Status.Upcoming) {
urlParameters = `seasons/${status.toLowerCase()}?page=`;
}
// TODO: Implementing return soon
return [];
};
export default useFetchAnimeListing;
In the above code, we have just created a custom hook, and I have created a variable named urlParameters
which basically creates a different URL in case of Status.Upcoming
(which is my use case here).
Next, well implement the return []
so replace it with the code below:
import { useInfiniteQuery } from '@tanstack/react-query';
//... other imports ../utils/constants
//... rest of the code until TODO
//... replacing the return[] to below
return useInfiniteQuery({
queryKey: ['getAnimeList', status],
queryFn: ({ pageParam }) => fetch(`${BaseURL}/${urlParameters}${pageParam}`).then((res) => {
if (!res.ok) {
throw new Error(`Network response was not ok, status ${res.status}`);
}
return res.json();
}),
initialPageParam: 1,
getNextPageParam: (lastPage, _allPages) => {
return lastPage.pagination?.has_next_page ? lastPage.pagination.current_page + 1 : undefined;
},
retry: 2,
});
//... rest of the code
Below is how the full useFetchAnimeListing
will turn out to be:
- In the
return
statement above we’re using theuseInfiniteQuery
hook, passing it a unique key i.e.getAnimeListAiring
etc which every bottom-tab user moves to will be passing instatus
hence this will ensure that thequeryKey
is unique as per bottom-tab data. - Next is the
queryFn
of the infinite-query hook, provides us withpageParam
property, that’ll keep the last page value that we’ll increment shortly. I’ve usedfetch
to call the API and if there is any error we throw the error otherwise returning data as JSON. - After the
queryFn
, we’ve added some more properties likeinitialPageParam
which as specified by the name will be the initial value of the page, this value will also be assigned topageParam
as it’ll undefined otherwise. - Next, I added
getNextPageParam
property which gives us two main things:pages
(renamed as _allPages just to tell what it is actually, and we’re not using it hence added _):
This is an array that contains the data for each page fetched so far. Each element in this array corresponds to the result of a single query (page).lastPage
:
This refers to the data returned by the most recent query. It's used to determine if there are more pages to fetch.
EXAMPLE: Say we have a total of three pages, so below is the example for what the values of pages and lastPage will be:
Initially:pages: []
andlastPage: undefined
After page 1 data fetched:pages: [page1]
andlastPage: page1
After page 2 data fetched:pages: [page1, page2]
andlastPage: page2
After page 3 data fetched:pages: [page1]
andlastPage: page3
If you read the documentation you will find that there is a cursor that is returned and in the case of the last page the cursor will be undefined, but it totally depends on your BE. Like in the API we’re taking has thepagination
object in which we have the info. So, I’m returning thegetNextPageParam
accordingly:return lastPage.pagination?.has_next_page ? lastPage.pagination.current_page + 1 : undefined;
- Lastly the
retry
is the same as we defined before. Here I have just given it to retry 2 times in case it fails to fetch.
Now to utilize the useFetchAnimeListing
, open Home.tsx
in which I currently have this:
//... Imports
interface HomeProps {
status: Status;
}
export default function Home({ status }: HomeProps) {
return (
<View style={styles.headerContainer}>
<Text style={styles.heading}>{status}</Text>
</View>
)
}
//... Styles
Import useFetchAnimeListing
and add this line:
import { useFetchAnimeListing } from '../hooks';
//... Other imports
//... export default line
const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage, refetch } = useFetchAnimeListing(status);
//... remaining return ( and Styles
You’re already familiar with data
, isLoading
, refetch
, and isError
which we did for useQuery
it’s the exact same. The hasNextPage
, fetchNextPage
, isFetchingNextPage
are available because we used useInfiniteQuery
. These do as their name describes, let me put it in the FlatList
so that you have a usage and then explain these properties. So, in the return (
of Home.tsx
add:
//... Rest of the code
{(data && data.pages.length !== 0) ? <FlatList
data={data.pages}
keyExtractor={(item, index) => item?.mal_id?.toString() + index.toString()}
renderItem={({ item }) => <AnimeItem animeItem={item} />}
showsVerticalScrollIndicator={false}
onEndReached={() => { if (hasNextPage) { fetchNextPage(); } }}
onEndReachedThreshold={0.5}
ListFooterComponent={
isFetchingNextPage ? <Loading /> : null
}
/> : <Text style={styles.noResultFoundLabel}>𓆝 𓆟 𓆞 𓆝 𓆟{'\n'}No results found{'\n'}𓆝 𓆟 𓆞 𓆝 𓆟</Text>}
//... Rest of the code
The .pages
is from the above explanation of pages
. The response API is going to return will have a data[]
in which every item has a mal_id
. Then <AnimeItem...
is my custom build component (the rounded rectangle). Then using FlatList’s onEndReached
we’re checking if hasNextPage
is true
then fetchNextPage()
. React-Query will automatically update what needs to be updated and incremented as per what we have defined ingetNextPageParam
. Then in the ListFooterComponent
we’re checking a loading state that if isFetchingNextPage
then show Loading. Pretty straight forward right?
To finish Home.tsx
we have added the loading and error states as:
if (isLoading) { return <Loading />; }
if (isError) {
return (
<View style={styles.errorViewContainer}>
<Text>Error occured. Please try again.</Text>
<TouchableOpacity onPress={() => refetch()}>
<Text style={styles.retryButton}>Retry</Text>
</TouchableOpacity>
</View>
);
}
Now let’s test it. It should run as below:
Details Screen
Details screen is much simpler than the Home screen. It’ll require us to call an API that will require the mal_id
and fetch details of a selected anime.
Optional UI: The UI of this screen is up to you. There is much more information that is retrieved from the API and I choose to only use the ones as shown below:
The code below is of Details.tsx
in the src/screens
folder:
I’ll go through the above quickly. Everything is inside a ScrollView
. Then the View
which I’m considering a headerContainer
has the featured image, title, source, year, duration, rating etc. As I have checked the response so I know that all the data I’m showing might not be available like some anime doesn’t have a trailer link for example, so I’m rendering the UI accordingly. Then after that, I added a favourite
button with empty callbacks, as we’ll cover it shortly. Finally, I’ve added details and background text based on the availability as per response.
I’m going to pass the mal_id
as a prop to the details screen I’ve created a type:
type DetailsScreenRouteProp = RouteProp<RootStackParamList, 'Details'>;
where RootStackParamList
is just a type I created to better manage the navigation:
export type RootStackParamList = {
AnimeTabs: undefined;
Favorites: undefined;
Details: { selectedAnimeItemMalId: AnimeCatalog.Anime['mal_id'] };
};
Hence the selectedAnimeItemMalId
will be the id I’ll be passing to the details screen.
Calling the API
As per the documentation, the API to call is:
Putting a valid mal_id
you’ll get a similar response as below.
We’ll create a custom-hook for this named useAnimeDetails
so in hooks
folder add a new file named useAnimeDetails.ts
and in it add the following code:
import { useQuery } from '@tanstack/react-query';
const useAnimeDetails = (malId: number) => {
return useQuery({
queryKey: ['getAnimeDetails', malId],
queryFn: () => fetch(`https://api.jikan.moe/v4/anime/${malId}`).then((res) => res.json()),
enabled: !!malId,
retry: 2,
});
};
export default useAnimeDetails;
Our useAnimeDetails
hook takes a parameter named malId
which as we know from our response will be a number. Same as what we have been doing I used useQuery
and provided a queryKey
as ['getAnimeDetails', malId]
so that for each anime the fetched details are unique and that is what the malId
will ensure, by making key unique for each anime details that is fetched, hence the data will be cached accordingly with the key. Then the queryFn
is the same using fetch
and returning res.json()
when response is found. A new thing here enabled: !!malId
which basically means do not run this query if the malId
is not available i.e. undefined
or null
. This adds an extra protective layer over the APi calls as it’ll avoid making unnecessary calls and will only call if an anime details has not been fetched before or is no longer in the cache. Then finally retry: 2
which depends on you on how much resource you want to allocate it to.
Now we go back to our Details.tsx
and let’s consume it. So add the following code:
//... Other code
export default function Details() {
const route = useRoute<DetailsScreenRouteProp>();
const { selectedAnimeItemMalId } = route.params;
const { data, isLoading, isError } = useAnimeDetails(selectedAnimeItemMalId);
if (isLoading) { return <Loading />; }
if (isError) { return <Text>Error occured. Please try again.</Text>; }
const {
title,
source,
duration,
rating,
score,
rank,
year,
popularity,
background,
synopsis,
images,
trailer,
} = data.data ?? {};
const openTrailerURL = async (url: string) => {
const supported = await Linking.canOpenURL(url);
if (supported) {
await Linking.openURL(url);
} else {
Alert.alert('There was a problem opening the trailer.');
}
};
//... Other code and same return (... as before
In the code above, we did the following:
- Fetching the prop passed to our
route
asparams
i.e.selectedAnimeItemMalId
(We’ll pass it via our custom AnimeItem component soon) - Passing the
selectedAnimeItemMalId
to the custom hook we just createduseAnimeDetails(selectedAnimeItemMalId)
and just like before gettingdata
,isLoading
, andisError
from RQ. - Rendering a message if
isLoading
orisError
- Destructuring the data to everything we need to display in details screen i.e. title, duration, rating, images, trailer etc.
- Added a function
openTrailerURL
that takes user to browser to open the trailer link if available.
Next open AnimeItem.tsx
file and as below we’ll add the onPress
of the Pressable
component we’ll navigate to Details
screen along with the animeItem.mal_id
which we are passing to this component from our flatlist i.e. <AnimeItem animeItem={item} />
onPress={() => navigation.navigate('Details', { selectedAnimeItemMalId: animeItem.mal_id })}
This should do it. You can test it and it should reflect as below. In the video below the first anime I’ll select is already in cache so you see no loading, but the 2nd and 3rd both shows loading. This is the react query caching in action.
Favourite Screen
There is no react-query involved in this. As we are storing the favourite anime user is picking in local storage. So I’ll go quickly over this section.
I’ve installed async-storage
:
yarn add @react-native-async-storage/async-storage
and created a custom hook named useFavourites
import { useEffect, useState, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { FAVORITES_KEY } from '../utils/constants';
const useFavourites = () => {
const [favourites, setFavourites] = useState<AnimeCatalog.Anime[]>([]);
const addFavourite = useCallback(async (anime: AnimeCatalog.Anime) => {
const updatedFavs = [anime, ...favourites];
await AsyncStorage.setItem(FAVORITES_KEY, JSON.stringify(updatedFavs));
setFavourites(updatedFavs);
}, [favourites]);
const removeFavourite = useCallback(async (malId: AnimeCatalog.Anime['mal_id']) => {
const updatedFavs = favourites.filter(anime => anime.mal_id !== malId);
await AsyncStorage.setItem(FAVORITES_KEY, JSON.stringify(updatedFavs));
setFavourites(updatedFavs);
}, [favourites]);
useEffect(() => {
loadFavourites();
}, [addFavourite, removeFavourite]);
const loadFavourites = async () => {
const fetchedFavs = await AsyncStorage.getItem(FAVORITES_KEY);
const favs = fetchedFavs ? JSON.parse(fetchedFavs) : [];
setFavourites(favs);
};
return {
favourites,
addFavourite,
removeFavourite,
};
};
export default useFavourites;
The above code returns all the favourites
that are in the local storage. addFavourite
add an anime to the favourite list and removeFavourite
remove it. loadFavourites
is called whenever addFavourite
or removeFavourite
is triggered. Straight-forward classic react!
Then in my Favourites.tsx
I’m rendering the data in a FlatList using the same AnimeItem component. Using useFavourites
to destructure the favourites
.
Our Details.tsx
is the file where we’re rendering a favourite button and that’ll perform adding and removing as favourite functionality. So next open that and add this code:
//... Other imports
import { useAnimeDetails, useFavourites } from '../hooks';
//... Other code
const { favourites, addFavourite, removeFavourite } = useFavourites();
const isFavourite = favourites.some(item => item.mal_id === selectedAnimeItemMalId);
const getFavButtonTitle = () => {
if (isFavourite) {
return '♡ Remove from Favourites';
}
return '♡ Add to Favourites';
};
const setFav = () => {
if (isFavourite) {
removeFavourite(selectedAnimeItemMalId);
} else {
const favData: AnimeCatalog.Anime = {
mal_id: selectedAnimeItemMalId,
images: {
jpg: {
image_url: images.jpg.image_url,
},
},
title, rating, score, year,
};
addFavourite(favData);
}
};
//... other return (...
<TouchableOpacity style={[styles.favButton, { backgroundColor: isFavourite ? COLORS.red : COLORS.blue }]} onPress={() => setFav()}>
<Text style={styles.favButtonLabel}>{getFavButtonTitle()}</Text>
</TouchableOpacity>
//... Remaining code
Destructuring favourites
, addFavourite
, removeFavourite
from useFavourites
. Created a const isFavourite
that sets a boolean value if the selected selectedAnimeItemMalId
is already marked as favourite or not. Based on the boolean we’re then setting what will be the title of the favourite button getFavButtonTitle
. When the favourite button is pressed it calls setFav
function which check isFavourite
first and proceed to removeFavourite otherwise prepare favData
and addFavourite
. The TouchableOpacity
button is calling the setFav
and getFavButtonTitle
accordingly.
🏆 Challenge: Using the knowledge you have just obtained, build a similar app but using https://rickandmortyapi.com API.
Extending Example 2 with some next level topics:
In this extension we’ll be working with some more tools provided by react-query. We’ll cover things like how to persist the cache, and polling.
Persisting the data:
What it means is that, if the user closes the app, the cache will get clear which means the next time user opens up the app it’ll have to refetch the data again. Say if our data freshness time is set to 30 minutes, and the user opens up the app again within 1 minute then they see loading again which from UX perspective is not a good experience. Apps like instagram and other big apps which when loading shows you cached data which are the posts you have already seen. Similar is the case here and this option is only based on your. use-case.
Good news is that, react-query provides persistQueryClient
which save your queryClient
for later use. Right now if we close our AnimeCatalog app and reopens, it’ll fetch the data again.
We worked with gcTime
in our App.tsx
where we set it to 30 Minutes, which meant the stored cache will be discarded after 30 minutes of inactivity. You can also pass it Infinity
to disable garbage collection behavior entirely.
// App.tsx
const MINUTE = 1000 * 60;
const queryClient = new QueryClient({
defaultOptions: {
// The options here will apply to all queries in QueryClientProvider client={queryClient}.
queries: {
gcTime: 30 * MINUTE, // garbage collection time.
},
},
});
We need to install two new packages:
yarn add @tanstack/query-async-storage-persister @tanstack/react-query-persist-client
Open App.tsx
and add this code:
//... other imports
import AsyncStorage from '@react-native-async-storage/async-storage';
import { QueryClient } from '@tanstack/react-query';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { PersistQueryClientProvider, removeOldestQuery } from '@tanstack/react-query-persist-client';
//... other imports
//... other code like const queryClient
const asyncStoragePersister = createAsyncStoragePersister({
key: 'AnimeCatalogQueryClient',
storage: AsyncStorage,
retry: removeOldestQuery,
});
//... other code
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: asyncStoragePersister }}
>
<NavigationContainer>
<MainDrawer />
</NavigationContainer>
</PersistQueryClientProvider>
);
//... remaininng code
- Instead of
QueryClientProvider
we’ll now usePersistQueryClientProvider
. - It has two required props i.e.
client
just as we added forQueryClientProvider
andpersistOptions
which we have created usingcreateAsyncStoragePersister
to which onlystorage
(we’re passingAsyncStorage
from@react-native-async-storage/async-storage
that we are using andstorage
is the only required property but I also added:key
(By default key will beREACT_QUERY_OFFLINE_CACHE
so I'm just passing my own)
And
(Persistence can fail, e.g. if the size exceeds the available space on the storage. Many of the retry function are provided by react-query like
retryremoveOldestQuery
. You can add a customnew PersistedClient
and pass it toretry
). - The
queryClient
is the same as when we first setgcTime
. - Finally, replace
QueryClientProvider
withPersistQueryClientProvider
and pass it bothqueryClient
andasyncStoragePersister
Now if you test the app, once the data is loaded and the app opens up again it doesn’t show loading any-more because the data is being loaded from local storage. As per my understanding react-query will still fetch the data from API, but in the mean-time it’s fetching it’ll show the persisted data and if there is an update in the data it’ll automatically invalidate the persisted data and loads the fresh one.
Polling
Polling is an important data-fetching technique if you have such use-case.
What is polling? — Involves the client (your app) repeatedly sending requests to the server at regular intervals, regardless of whether the data has changed. It’s suitable when you need to refresh data periodically and don’t know exactly when it might change.
To do polling, we’ll need to add a property refetchInterval
to our react-query hook. Below is an example of it in useQuery
you can also add it to others like useMutation
etc.
//... useAnimeDetails.tsx
//... other code
return useQuery({
queryKey: ['getAnimeDetails', malId],
queryFn: () => fetch(`https://api.jikan.moe/v4/anime/${malId}`).then((res) => res.json()),
enabled: !!malId,
retry: 2,
// Refetch the data every second
refetchInterval: 1000, // Query Interval speed (ms)
});
//... other code
The refetchInterval
is set to refetch every 1 second. If set to a number
, the query will continuously refetch at this frequency in milliseconds. If set to a function
, the function
will be executed with the latest data
and query to compute a frequency. Defaults to false
.
Say you want to enable the refetch for some-time and closed for other times? You can implement your logic by providing a function
to refetchInterval
. Below is a very basic example:
You can implement your logic with the data and based on your logic you can return false
to disable refetching, similarly if you have some other logic you can return true
and it’ll enable it again, and all of this will happen in refetchIntervalFuncExample
. You can return number
or boolean
or undefined
. Further, you can read the Polling example comments.
Here is all the code of the react-native app up to this point:
These were just two of the many more things you can extend your react-query with.
Conclusion
React-Query is a huge library with a lot more features and I have covered here some of the main ones. There documentation of the library is really good.
Another Recommended Read, would be:
Congratulations on making it to the end. If you have any questions or are stuck on a step feel free to connect on LinkedIn and ask.
Like to support? Scan the QR to go to my BuyMeACoffee profile:
Extras
Some additional things, if you’re interested to explore.
Optimistic UI
You might have seen it in apps like instagram. Where when you write a comment it shows instantly but it’s a bit faded and shows loading… next to it. This is an example of optimistic UI, where we know that server is being updated and we show user a preview of it until the server state is updated and latest data is successfully fetched then we remove the fading and loading from it. There can be many use-cases of this but this is just one of it. In the below GIF, you can see I’ll add a todo, and it’ll show instantly with a faded color and as soon as the server is updated, it’ll reload the data. This example’s code can be found here.
Suspense
It’s not a widely adapted yet as it’s launched not very long ago, but a TLDR; is if your data is bing fetched react loads a temporary component instead until the data becomes available.
I wrote a TLDR; in case you’re interested to learn more:
For people like me coming from Redux-Saga background, you can use redux and react query together at start that also works fine. You can also implement other state management libraries like xstate, zustand, context api etc too. I might be adding more in the future to extras section in the future regarding this.
Here are some open APIs you can use to practice: