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.

Chaudhry Talha 🇵🇸
37 min readJun 1, 2024

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:

Optional: You can also create a demo CRUD api using https://beeceptor.com/ (50 requests per day limit)

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.

I commented <PostsFetchExample… and added <Products…

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 and gcTime is. I haven’t added gcTime 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 implementing fetchProductById 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
    From Home or Favorites 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.

Home Screen— Details Screen — Drawer Navigation

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.

I have also created two components name <Loading /> which contains an ActivityIndicator and a <Divider /> component which contains a View of height height: StyleSheet.hairlineWidth.

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.

Loading Component — When Data is Loaded — When no data is found

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:

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:

App.tsx Bottom tab — Status passed to Home.tsx in Props

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 the useInfiniteQuery hook, passing it a unique key i.e. getAnimeListAiring etc which every bottom-tab user moves to will be passing in status hence this will ensure that the queryKey is unique as per bottom-tab data.
  • Next is the queryFn of the infinite-query hook, provides us with pageParam property, that’ll keep the last page value that we’ll increment shortly. I’ve used fetch 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 like initialPageParam which as specified by the name will be the initial value of the page, this value will also be assigned to pageParam 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: [] and lastPage: undefined
    After page 1 data fetched: pages: [page1] and lastPage: page1
    After page 2 data fetched: pages: [page1, page2] and lastPage: page2
    After page 3 data fetched: pages: [page1] and lastPage: 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 the pagination object in which we have the info. So, I’m returning the getNextPageParam 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 as params i.e. selectedAnimeItemMalId (We’ll pass it via our custom AnimeItem component soon)
  • Passing the selectedAnimeItemMalId to the custom hook we just created useAnimeDetails(selectedAnimeItemMalId) and just like before getting data, isLoading, and isError from RQ.
  • Rendering a message if isLoading or isError
  • 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 use PersistQueryClientProvider.
  • It has two required props i.e. client just as we added for QueryClientProvider and persistOptions which we have created using createAsyncStoragePersister to which only storage (we’re passing AsyncStorage from @react-native-async-storage/async-storage that we are using and storage is the only required property but I also added:
    key (By default key will be REACT_QUERY_OFFLINE_CACHE so I'm just passing my own)
    And
    retry
    (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 removeOldestQuery. You can add a custom new PersistedClient and pass it to retry).
  • The queryClient is the same as when we first set gcTime.
  • Finally, replace QueryClientProvider with PersistQueryClientProvider and pass it both queryClient and asyncStoragePersister

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.

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.

If you find this article useful do press 👏👏👏 so that others can also find this article.

Like to support? Scan the QR to go to my BuyMeACoffee profile:

https://www.buymeacoffee.com/chaudhrytalha

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:

--