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:

  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

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:

  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

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:

  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

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

  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.

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.