React (TanStack) Query Tutorial for Beginners
Originally published on my personal blog.
React Query (now rebranded to TanStack Query) is a React library used to make fetching and manipulating server-side data easier. Using React Query, you can implement, along with data fetching, caching, and synchronization of your data with the server.
In this tutorial, you’ll build a simple Node.js server and then learn how to interact with it on a React website using React Query.
Please note that this version uses v4 of React Query which is now named TanStack Query.
You can find the code for this tutorial in this GitHub repository.
Prerequisites
Before starting with this tutorial make sure you have Node.js installed. You need at least version 14.
Server Setup
In this section, you’ll set up a simple Node.js server with an SQLite database. The server has 3 endpoints to fetch, add, and delete notes.
If you already have a server you can skip this section and go to the Website Setup section.
Create Server Project
Create a new directory called server
then initialize a new project using NPM:
mkdir server
cd server
npm init -y
Install Dependencies
Then, install the packages you’ll need for the development of the server:
npm i express cors body-parser sqlite3 nodemon
Here’s what each of the packages is for:
express
to create a server using Express.cors
is an Express middleware used to handle CORS on your server.body-parser
is an Express middleware used to parse the body of a request.sqlite3
is an SQLite database adapter for Node.js.nodemon
is a library used to restart the server whenever new changes occur to the files.
Create Server
Create the file index.js
with the following content:
const express = require('express');
const app = express();
const port = 3001;
const cors = require('cors');
const sqlite3 = require('sqlite3').verbose();
const bodyParser = require('body-parser');
app.use(bodyParser.json());
app.use(cors());
app.listen(port, () => {
console.log(`Notes app listening on port ${port}`);
});
This initializes the server using Express on port 3001
. It also uses the cors
and body-parser
middleware.
Then, in package.json
add a new script start
to run the server:
"scripts": {
"start": "nodemon index.js"
},
Initialize the Database
In index.js
before app.listen
add the following code:
const db = new sqlite3.Database('data.db', (err) => {
if (err) {
throw err;
}
// create tables if they don't exist
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, content TEXT,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP)`);
});
});
This creates a new database if it doesn’t exist in the file data.db
. Then, if the notes
table doesn't exist on the database it creates it as well.
Add Endpoints
Following the database code, add the following code to add the endpoints:
app.get('/notes', (req, res) => {
db.all('SELECT * FROM notes', (err, rows) => {
if (err) {
console.error(err);
return res.status(500).json({ success: false, message: 'An error occurred, please try again later' });
}
return res.json({ success: true, data: rows });
});
});
app.get('/notes/:id', (req, res) => {
db.get('SELECT * FROM notes WHERE id = ?', req.params.id, (err, row) => {
if (err) {
console.error(err);
return res.status(500).json({ success: false, message: 'An error occurred, please try again later' });
}
if (!row) {
return res.status(404).json({ success: false, message: 'Note does not exist' });
}
return res.json({ success: true, data: row });
});
});
app.post('/notes', (req, res) => {
const { title, content } = req.body;
if (!title || !content) {
return res.status(400).json({ success: false, message: 'title and content are required' });
}
db.run('INSERT INTO notes (title, content) VALUES (?, ?)', [title, content], function (err) {
if (err) {
console.error(err);
return res.status(500).json({ success: false, message: 'An error occurred, please try again later' });
}
return res.json({
success: true,
data: {
id: this.lastID,
title,
content,
},
});
});
});
app.delete('/notes/:id', (req, res) => {
const { id } = req.params;
db.get('SELECT * FROM notes WHERE id = ?', [id], (err, row) => {
if (err) {
console.error(err);
return res.status(500).json({ success: false, message: 'An error occurred, please try again later' });
}
if (!row) {
return res.status(404).json({ success: false, message: 'Note does not exist' });
}
db.run('DELETE FROM notes WHERE id = ?', [id], (error) => {
if (error) {
console.error(error);
return res.status(500).json({ success: false, message: 'An error occurred, please try again later' });
}
return res.json({ success: true, message: 'Note deleted successfully' });
});
});
});
Briefly, this creates 4 endpoints:
/notes
endpoint of the methodGET
to fetch all notes./notes/:id
endpoint of the methodGET
to fetch a note by an ID./notes
endpoint of the methodPOST
to add a note./notes/:id
endpoint of the methodDELETE
to delete a note.
Test Server
Run the following command to start the server:
npm start
This starts the server on port 3001
. You can test it out by sending a request to localhost:3001/notes
.
Website Setup
In this section, you’ll create the website with Create React App (CRA). This is where you’ll make use of React Query.
Create Website Project
To create a new React app, run the following command in a different directory:
npx create-react-app website
This creates a new React app in the directory website
.
Install Dependencies
Run the following command to change to the website
directory and install the necessary dependencies for the website:
cd website
npm i @tanstack/react-query tailwindcss postcss autoprefixer @tailwindcss/typography @heroicons/react @windmill/react-ui
The @tanstack/react-query
library is the React Query library which is now named TanStack Query. The other libraries are Tailwind CSS related libraries to add styling to the website.
Tailwind CSS Setup
This section is optional and is only used to set up Tailwind CSS.
Create the file postcss.config.js
with the following content:
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Also, create the file tailwind.config.js
with the following content:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography')
],
}
Then, create the file src/index.css
with the following content:
@tailwind base;
@tailwind components;
@tailwind utilities;
Finally, in index.js
import src/index.css
at the beginning of the file:
import './index.css';
Use QueryClientProvider
To use the React Query client in all of your components, you must use it at a high level in your website’s components hierarchy. The best place to put it is in src/index.js
which wraps up your entire website's components.
In src/index.js
add the following imports at the beginning of the file:
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
Then, initialize a new Query client:
const queryClient = new QueryClient()
Finally, change the parameter passed to root.render
:
root.render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
This wraps the App
component which holds the rest of the website's components with QueryClientProvider
. This provider accepts the prop client
which is an instance of QueryClient
.
Now, all components within the website will have access to the Query Client which is used to fetch, cache, and manipulate the server data.
Implement Display Notes
Fetching data from the server is an act of performing a query. Therefore, you’ll use useQuery
in this section.
You’ll display notes in the App
component. These notes are fetched from the server using the /notes
endpoint.
Replace the content of app.js
with the following content:
import { PlusIcon, RefreshIcon } from '@heroicons/react/solid'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
function App() {
const { isLoading, isError, data, error } = useQuery(['notes'], fetchNotes)
function fetchNotes () {
return fetch('http://localhost:3001/notes')
.then((response) => response.json())
.then(({ success, data }) => {
if (!success) {
throw new Error ('An error occurred while fetching notes');
}
return data;
})
}
return (
<div className="w-screen h-screen overflow-x-hidden bg-red-400 flex flex-col justify-center items-center">
<div className='bg-white w-full md:w-1/2 p-5 text-center rounded shadow-md text-gray-800 prose'>
<h1>Notes</h1>
{isLoading && <RefreshIcon className="w-10 h-10 animate-spin mx-auto"></RefreshIcon>}
{isError && <span className='text-red'>{error.message ? error.message : error}</span>}
{!isLoading && !isError && data && !data.length && <span className='text-red-400'>You have no notes</span>}
{data && data.length > 0 && data.map((note, index) => (
<div key={note.id} className={`text-left ${index !== data.length - 1 ? 'border-b pb-2' : ''}`}>
<h2>{note.title}</h2>
<p>{note.content}</p>
<span>
<button className='link text-gray-400'>Delete</button>
</span>
</div>
))}
</div>
<button className="mt-2 bg-gray-700 hover:bg-gray-600 rounded-full text-white p-3">
<PlusIcon className='w-5 h-5'></PlusIcon>
</button>
</div>
);
}
export default App;
Here’s briefly what’s going on in this code snippet:
- You use
useQuery
to fetch the notes. The first parameter it accepts is a unique key used for caching. The second parameter is the function used to fetch the data. You pass it thefetchNotes
function. useQuery
returns an object that holds many variables. Here, you use 4 of them:isLoading
is a boolean value that determines whether the data is currently being fetched;isError
is a boolean value that determines if an error occurred.data
is the data that is fetched from the server; anderror
is the error message ifisError
is true.- The
fetchNotes
function must return a promise that either resolves data or throws an error. In the function, you send aGET
request tolocalhost:3001/notes
to fetch the notes. If the data is fetched successfully it is returned in thethen
fulfillment function. - In the returned JSX, if
isLoading
is true, a loading icon is shown. IfisError
is true, an error message is shown. Ifdata
is fetched successfully and has any data in it, the notes are rendered. - You also show a button with a plus icon to add new notes. You’ll implement this later.
Test Displaying Notes
To test out what you’ve implemented so far, make sure your server is still running, then start your React app server with the following command:
npm start
This runs your React app on localhost:3000
by default. If you open it in your browser, you'll see a loading icon at first then you'll see no notes as you haven't added any yet.
Implement Add Notes Functionality
Adding a note is an act of mutation on the server data. Therefore, you’ll be using the useMutation
hook in this section.
You’ll create a separate component that shows the form used to add a note.
Create the file src/form.js
with the following content:
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
export default function Form ({ isOpen, setIsOpen }) {
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
const queryClient = useQueryClient()
const mutation = useMutation(insertNote, {
onSuccess: () => {
setTitle("")
setContent("")
}
})
function closeForm (e) {
e.preventDefault()
setIsOpen(false)
}
function insertNote () {
return fetch(`http://localhost:3001/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title,
content
})
})
.then((response) => response.json())
.then(({ success, data }) => {
if (!success) {
throw new Error("An error occured")
}
setIsOpen(false)
queryClient.setQueriesData('notes', (old) => [...old, data])
})
}
function handleSubmit (e) {
e.preventDefault()
mutation.mutate()
}
return (
<div className={`absolute w-full h-full top-0 left-0 z-50 flex justify-center items-center ${!isOpen ? 'hidden' : ''}`}>
<div className='bg-black opacity-50 absolute w-full h-full top-0 left-0'></div>
<form className='bg-white w-full md:w-1/2 p-5 rounded shadow-md text-gray-800 prose relative'
onSubmit={handleSubmit}>
<h2 className='text-center'>Add Note</h2>
{mutation.isError && <span className='block mb-2 text-red-400'>{mutation.error.message ? mutation.error.message : mutation.error}</span>}
<input type="text" placeholder='Title' className='rounded-sm w-full border px-2'
value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea onChange={(e) => setContent(e.target.value)}
className="rounded-sm w-full border px-2 mt-2" placeholder='Content' value={content}></textarea>
<div>
<button type="submit" className='mt-2 bg-red-400 hover:bg-red-600 text-white p-3 rounded mr-2 disabled:pointer-events-none'
disabled={mutation.isLoading}>
Add</button>
<button className='mt-2 bg-gray-700 hover:bg-gray-600 text-white p-3 rounded'
onClick={closeForm}>Cancel</button>
</div>
</form>
</div>
)
}
Here’s a brief explanation of this form
- This form acts as a pop-up. It accepts
isOpen
andsetIsOpen
props to determine when the form is opened and handle closing it. - You use
useQueryClient
to get access to the Query Client. This is necessary to perform a mutation. - To handle adding a note on your server and keep all data in your query client synced, you must the
useMutation
hook. - The
useMutation
hook accepts 2 parameters. Thie first one is the function that will handle the mutation, which in this case isinsertNote
. The second parameter is an object of options. You pass it one optiononSuccess
which is a function that runs if the mutation is performed successfully. You use this to reset thetitle
andcontent
fields of the form. - In
insertNote
, you send aPOST
request tolocalhost:3001/notes
and pass in the body thetitle
andcontent
of the note to be created. If thesuccess
body parameter returned from the server isfalse
, an error is thrown to signal that the mutation failed. - If the note is added successfully, you change the cached value of the
notes
key using thequeryClient.setQueriesData
method. This method accepts the key as a first parameter and the new data associated with that key as a second parameter. This updates the data everywhere it's used on your website. - In this component you display a form with 2 fields:
title
andcontent
. In the form, you check if an error occurs usingmutation.isError
and get access to the error usingmutation.error
. - You handle form submission in the
handleSubmit
function. Here, you trigger the mutation usingmutation.mutate
. This is where theinsertNote
function is triggered to add a new note.
Then, in src/app.js
add the following imports at the beginning of the file:
import Form from './form'
import { useState } from 'react'
Then, at the beginning of the component add a new state variable to manage wheter the form is opened or not:
const [isOpen, setIsOpen] = useState(false)
Next, add a new function addNote
that just uses setIsOpen
to open the form:
function addNote () {
setIsOpen(true)
}
Finally, in the returned JSX, replace the button with the plus icon with the following:
<button className="mt-2 bg-gray-700 hover:bg-gray-600 rounded-full text-white p-3" onClick={addNote}>
<PlusIcon className='w-5 h-5'></PlusIcon>
</button>
<Form isOpen={isOpen} setIsOpen={setIsOpen} />
This sets the onClick
handler of the button to addNote
. It also adds the Form
component you created earlier as a child component of App
.
Test Adding a Note
Rerun your server and React app if they’re not running. Then, open the website again at localhost:3000
. Click on the plus button and a pop up will open with the form to add a new note.
Enter a random title and content then click Add. The pop up form will then close and you can see the new note added.
Implement Delete Note Functionality
The last functionality you’ll add is deleting notes. Deleting a note is another act of mutation as it manipulates the server’s data.
At the beginning of the App
component in src/app.js
add the following code:
const queryClient = useQueryClient()
const mutation = useMutation(deleteNote, {
onSuccess: () => queryClient.invalidateQueries('notes')
})
Here, you get access to the query client using useQueryClient
. Then, you create a new mutation using useMutation
. You pass it the function deleteNote
(which you'll create next) as a first parameter and an object of options.
To the onSuccess
option you pass a function that does one thing. It executes the method queryClient.invalidateQueries
. This method marks the cached data for a specific key as outdated, which triggers retrieving the data again.
So, once a note is deleted, the query you created earlier that executes the function fetchNotes
will be triggered and the notes will be fetched again. If you had created other queries on your website that use the same key notes
, they'll also be triggered to update their data.
Next, add the function deleteNote
in the App
component in the same file:
function deleteNote (note) {
return fetch(`http://localhost:3001/notes/${note.id}`, {
method: 'DELETE'
})
.then((response) => response.json())
.then(({ success, message }) => {
if (!success) {
throw new Error(message);
}
alert(message);
})
}
This function receives the note
to be deleted as a parameter. It sends a DELETE
request to localhost:3001/notes/:id
. If the success
body parameter of the response is false
, an error is thrown. Otherwise, only an alert is shown.
Then, in the returned JSX of the App
component, change how the loading icon and error where shown previously to the following:
{(isLoading || mutation.isLoading) && <RefreshIcon className="w-10 h-10 animate-spin mx-auto"></RefreshIcon>}
{(isError || mutation.isError) && <span className='text-red'>{error ? (error.message ? error.message : error) : mutation.error.message}</span>}
This shows the loading icon or the error message for both the query that fetches the notes and the mutation that handles deleting a note.
Finally, find the delete button of a note and add an onClick
handler:
<button className='link text-gray-400' onClick={() => mutation.mutate(note)}>Delete</button>
On click, the mutation responsible for deleting the note is triggered using mutation.mutate
. You pass it the note to delete which is the current note in a map
loop.
Test Deleting a Note
Rerun your server and React app if they’re not running. Then, open the website again at localhost:3000
. Click the Delete link for any of your notes. If the note is deleted successfully, an alert will be shown.
After closing the alert, the notes will be fetched again and displayed, if there are any other notes.
Conclusion
Using React (TanStack) Query, you can easily handle server data fetching and manipulation on your website with advanced features such as caching and synchronization across your React app.
Make sure to check out the official documentation to learn more about what you can do with React Query.