React (TanStack) Query Tutorial for Beginners

Prerequisites

Server Setup

Create Server Project

mkdir server
cd server
npm init -y

Install Dependencies

npm i express cors body-parser sqlite3 nodemon
  1. express to create a server using Express.
  2. cors is an Express middleware used to handle CORS on your server.
  3. body-parser is an Express middleware used to parse the body of a request.
  4. sqlite3 is an SQLite database adapter for Node.js.
  5. nodemon is a library used to restart the server whenever new changes occur to the files.

Create Server

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}`);
});
"scripts": {
"start": "nodemon index.js"
},

Initialize the Database

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)`);
});
});

Add 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' });
});
});
});
  1. /notes endpoint of the method GET to fetch all notes.
  2. /notes/:id endpoint of the method GET to fetch a note by an ID.
  3. /notes endpoint of the method POST to add a note.
  4. /notes/:id endpoint of the method DELETE to delete a note.

Test Server

npm start

Website Setup

Create Website Project

npx create-react-app website

Install Dependencies

cd website
npm i @tanstack/react-query tailwindcss postcss autoprefixer @tailwindcss/typography @heroicons/react @windmill/react-ui

Tailwind CSS Setup

module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography')
],
}
@tailwind base;
@tailwind components;
@tailwind utilities;
import './index.css';

Use QueryClientProvider

import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
const queryClient = new QueryClient()
root.render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);

Implement Display Notes

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;
  1. 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 the fetchNotes function.
  2. 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; and error is the error message if isError is true.
  3. The fetchNotes function must return a promise that either resolves data or throws an error. In the function, you send a GET request to localhost:3001/notes to fetch the notes. If the data is fetched successfully it is returned in the then fulfillment function.
  4. In the returned JSX, if isLoading is true, a loading icon is shown. If isError is true, an error message is shown. If data is fetched successfully and has any data in it, the notes are rendered.
  5. You also show a button with a plus icon to add new notes. You’ll implement this later.

Test Displaying Notes

npm start

Implement Add Notes Functionality

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>
)
}
  1. This form acts as a pop-up. It accepts isOpen and setIsOpen props to determine when the form is opened and handle closing it.
  2. You use useQueryClient to get access to the Query Client. This is necessary to perform a mutation.
  3. To handle adding a note on your server and keep all data in your query client synced, you must the useMutation hook.
  4. The useMutation hook accepts 2 parameters. Thie first one is the function that will handle the mutation, which in this case is insertNote. The second parameter is an object of options. You pass it one option onSuccess which is a function that runs if the mutation is performed successfully. You use this to reset the title and content fields of the form.
  5. In insertNote, you send a POST request to localhost:3001/notes and pass in the body the title and content of the note to be created. If the success body parameter returned from the server is false, an error is thrown to signal that the mutation failed.
  6. If the note is added successfully, you change the cached value of the notes key using the queryClient.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.
  7. In this component you display a form with 2 fields: title and content. In the form, you check if an error occurs using mutation.isError and get access to the error using mutation.error.
  8. You handle form submission in the handleSubmit function. Here, you trigger the mutation using mutation.mutate. This is where the insertNote function is triggered to add a new note.
import Form from './form'
import { useState } from 'react'
const [isOpen, setIsOpen] = useState(false)
function addNote () {
setIsOpen(true)
}
<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} />

Test Adding a Note

Implement Delete Note Functionality

const queryClient = useQueryClient()
const mutation = useMutation(deleteNote, {
onSuccess: () => queryClient.invalidateQueries('notes')
})
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);
})
}
{(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>}
<button className='link text-gray-400' onClick={() => mutation.mutate(note)}>Delete</button>

Test Deleting a Note

Conclusion

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store