In this comprehensive guide, you’ll learn how to build a CRUD application with React.js, Tailwind CSS, React Query, React-Hook-Form, and Axios. In brief, we’ll use CRUD (CREATE, READ, UPDATE, DELETE) methods to perform basic operations against a data layer via HTTP protocol.

To make the React app communicate with a backend API, the “Run the Django CRUD API Locally” section will guide you on how you can spin up a CRUD API built with Django and Python.

More practice:

Build a React.js CRUD App using a RESTful API

Prerequisites

Despite organizing this tutorial with beginners in mind, these prerequisites are needed to get the most out of the course.

  • Basic knowledge of React, Tailwind CSS, TypeScript, and JavaScript
  • Basic knowledge of CRUD architecture and how to communicate with an API

Run the Django CRUD API Locally

  • Make sure you have Python installed. Visit https://www.python.org/downloads/ to install the right binary for your operating system.
  • Download or clone the Django CRUD API source code from https://github.com/wpcodevo/Django_Crud_Project and open the project with an IDE.
  • Create a virtual environment by running the command below in the integrated terminal of your IDE or text editor:
    • Windows OSpython -m venv venv
    • Mac or Linux OSpython3 -m venv venv
  • After the virtual environment has been created, your IDE or text editor might prompt you to activate it in the current workspace. Click “Yes” to accept the action.
    click on Yes to activate the python virtual environment
    Note: You need to close and reopen the integrated terminal to activate the virtual environment in the terminal.
    If for any reason your IDE or text editor didn’t prompt you to activate the newly-created virtual environment, run the command below to manually activate it.
    • Windows OS (Command Prompt ) – venv\Scripts\activate.bat.
    • Windows OS (Git Bash) – venv/Scripts/activate.bat.
    • Mac or Linux OS – source venv/bin/activate
  • Run pip install -r requirements.txt to install all the project’s dependencies.
  • Run python manage.py migrate to migrate the database schema to the SQLite database.
  • Start the Django development server on port 8000 by running python manage.py runserver
  • Set up the frontend app to test the Django CRUD API or make the HTTP requests from an API testing software.

Run the React CRUD APP Locally

  • Visit https://github.com/wpcodevo/reactjs-crud-note-app to download or clone the React.js CRUD project. After that, open the project with an IDE or text editor.
  • Open the integrated terminal in your IDE or text editor and run yarn or yarn install to install all the project’s dependencies.
  • Run yarn dev to start the Vite development server on port 3000
  • Navigate to http://localhost:3000/ in a new tab to test the React CRUD app against the Django API.

Setup the React.js Project

In this section, you’ll bootstrap a new React.js project with the Vite scaffolding tool and configure the app to use Tailwind CSS for styling.

Bootstrap React with Vite

First things first, navigate to the location where you what the React.js source code to reside and run this command to start the project scaffolding.


yarn create vite react-crud-app
# or
npm create vite react-crud-app

After the vite-create process has been executed, select React as the framework and TypeScript as the variant.

Next, open the newly-generated project in an IDE or text editor and run yarn install or npm install in the integrated terminal to install all the required dependencies.

Once the installation is complete, open the package.json file and replace the dev script with:

package.json


{
"dev": "vite --host localhost --port 3000"
}

This will allow us to start the development server on port 3000 instead of the default port that comes with Vite.

Set up Tailwind CSS

Now that the project is ready, run the command below to install the dependencies needed for Tailwind CSS.


yarn add -D tailwindcss postcss autoprefixer
# or
npm install -D tailwindcss postcss autoprefixer

Generate the Tailwind CSS configuration files in the project with this command:


yarn tailwindcss init -p
# or
npx tailwindcss init -p

Open the tailwind.config.cjs file and replace its content with the following configurations:

tailwind.config.cjs


/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./index.html",
    "./src/**/*.{vue,js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        'ct-dark-600': '#222',
        'ct-dark-200': '#575757',
        'ct-dark-100': '#6d6d6d',
        'ct-blue-600': '#e35def',
        'ct-blue-700': '#db34eb',
        'ct-yellow-600': '#f9d13e',
      },
      fontFamily: {
        Poppins: ['Poppins, sans-serif'],
      },
      container: {
        center: true,
        padding: '1rem',
        screens: {
          lg: '1125px',
          xl: '1125px',
          '2xl': '1125px',
          '3xl': '1500px'
        },
      },
    },
  },
  plugins: [],
};

In the above code, we specified the paths to the template files in the content array, added some custom colors in the colors object, added a custom font in the fontFamily object, and modified the container with custom styles.

Now open the src/index.css file and replace its content with the following Tailwind CSS directives and CSS styles.

src/index.css


@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap');

#nprogress .bar {
    background: #66ffff !important;
    height: 3px !important;
}

#nprogress .spinner .spinner-icon {
    width: 22px;
    height: 22px;
    border-top-color: #66ffff !important;
    border-left-color: #66ffff !important;
}

#nprogress .spinner {
    top: 15px;
    left: 15px !important;
}

@tailwind base;
@tailwind components;
@tailwind utilities;

html{
    font-family: 'Poppins', sans-serif;
}

body{
    background-color: #e35def;
}

Create API Requests with Axios

In this section, you’ll set up Axios, and create the CRUD functions that React Query will use to perform the CRUD operations. To begin, open your terminal and install the Axios package.


yarn add axios
# or
npm install axios

Let’s create TypeScript types that we’ll provide to the CRUD functions to get better TypeScript inference about the responses the API will return to the React app. So, create a src/api/types.ts file and add the following types.

src/api/types.ts


export type INote = {
  id: string;
  title: string;
  content: string;
  createdAt: Date;
  updatedAt: Date;
};

export type IGenericResponse = {
  status: string;
  message: string;
};

export type INoteResponse = {
  status: string;
  note: INote;
};

export type INotesResponse = {
  status: string;
  results: number;
  notes: INote[];
};

With that out of the way, create a src/api/noteApi.ts file and add the following Axios code.

src/api/noteApi.ts


import axios from "axios";
import { CreateNoteInput } from "../components/notes/create.note";
import { UpdateNoteInput } from "../components/notes/update.note";
import { INote, INoteResponse, INotesResponse } from "./types";

const BASE_URL = "http://localhost:8000/api/";

export const noteApi = axios.create({
  baseURL: BASE_URL,
  withCredentials: true,
});

noteApi.defaults.headers.common["Content-Type"] = "application/json";

export const createNoteFn = async (note: CreateNoteInput) => {
  const response = await noteApi.post<INoteResponse>("notes/", note);
  return response.data;
};

export const updateNoteFn = async (noteId: string, note: UpdateNoteInput) => {
  const response = await noteApi.patch<INoteResponse>(`notes/${noteId}`, note);
  return response.data;
};

export const deleteNoteFn = async (noteId: string) => {
  return noteApi.delete<null>(`notes/${noteId}`);
};

export const getSingleNoteFn = async (noteId: string) => {
  const response = await noteApi.get<INoteResponse>(`notes/${noteId}`);
  return response.data;
};

export const getNotesFn = async (page = 1, limit = 10) => {
  const response = await noteApi.get<INotesResponse>(
    `notes?page=${page}&limit=${limit}`
  );
  return response.data;
};

Note: we’ll create the CreateNoteInput and UpdateNoteInput types in a bit.

We defined the URL of the API server and created an Axios instance with a config object. After that, we added a default config to the Axios instance that will be applied to every request executed by the instance.

  • createNoteFn – React Query will evoke this function to make a POST request to the API with the payload included in the request body.
  • updateNoteFn – React Query will evoke this function to make a PATCH request to the API with the payload included in the request body.
  • deleteNoteFn – React Query will evoke this function to make a DELETE request to the API with the ID of the record to be deleted appended in the URL parameter.
  • getSingleNoteFn – React Query will evoke this function to make a GET request to the API with the ID of the record to be retrieved appended in the URL parameter.
  • getNotesFn – React Query will evoke this function to make a GET request to retrieve a list of records from the API.

Create Reusable Components

In this section, you’ll create some reusable components with React.js and style them with tailwind CSS. First, install the tailwind-merge package to help us merge Tailwind CSS classes in JS without any style conflicts.


yarn add tailwind-merge
# or
npm install tailwind-merge

The first component will be a Spinner that will be displayed whenever a request is being processed by the API. The Spinner component will be hidden by default but when a request is in flight, it will be made visible to indicate that the request is being handled by the API.

So, create a src/components/Spinner.tsx file and add the following TSX code:

src/components/Spinner.tsx


import React from 'react';
import { twMerge } from 'tailwind-merge';
type SpinnerProps = {
  width?: number;
  height?: number;
  color?: string;
  bgColor?: string;
};
const Spinner: React.FC<SpinnerProps> = ({
  width = 5,
  height = 5,
  color,
  bgColor,
}) => {
  return (
    <svg
      role='status'
      className={twMerge(
        'w-5 h-5 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600',
        `w-${width} h-${height} ${color} ${bgColor}`
      )}
      viewBox='0 0 100 101'
      fill='none'
      xmlns='http://www.w3.org/2000/svg'
    >
      <path
        d='M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z'
        fill='currentColor'
      />
      <path
        d='M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z'
        fill='currentFill'
      />
    </svg>
  );
};

export default Spinner;

The second component is a custom button that will be used in other components to submit form data or trigger an event. This component will also display the Spinner component when the form data is being submitted to the API.

To create the custom button, create a src/components/LoadingButton.tsx file and add the TSX code below.

src/components/LoadingButton.tsx


import React from "react";
import { twMerge } from "tailwind-merge";
import Spinner from "./Spinner";

type LoadingButtonProps = {
  loading: boolean;
  btnColor?: string;
  textColor?: string;
  children: React.ReactNode;
};

export const LoadingButton: React.FC<LoadingButtonProps> = ({
  textColor = "text-white",
  btnColor = "bg-ct-blue-700",
  children,
  loading = false,
}) => {
  return (
    <button
      type="submit"
      className={twMerge(
        `w-full py-3 font-semibold ${btnColor} rounded-lg outline-none border-none flex justify-center`,
        `${loading && "bg-[#ccc]"}`
      )}
    >
      {loading ? (
        <div className="flex items-center gap-3">
          <Spinner />
          <span className="text-white inline-block">Loading...</span>
        </div>
      ) : (
        <span className={`text-lg font-normal ${textColor}`}>{children}</span>
      )}
    </button>
  );
};

Here, you’ll create a modal popup component that will be rendered outside the current parent/child hierarchy using React portal. To do this, create a src/components/note.modal.tsx file and add the following code snippets.

src/components/note.modal.tsx


import ReactDom from "react-dom";
import React, { FC } from "react";

type INoteModal = {
  openNoteModal: boolean;
  setOpenNoteModal: (open: boolean) => void;
  children: React.ReactNode;
};

const NoteModal: FC<INoteModal> = ({
  openNoteModal,
  setOpenNoteModal,
  children,
}) => {
  if (!openNoteModal) return null;
  return ReactDom.createPortal(
    <>
      <div
        className="fixed inset-0 bg-[rgba(0,0,0,.5)] z-[1000]"
        onClick={() => setOpenNoteModal(false)}
      ></div>
      <div className="max-w-lg w-full rounded-md fixed top-0 xl:top-[10%] left-1/2 -translate-x-1/2 bg-white z-[1001] p-6">
        {children}
      </div>
    </>,
    document.getElementById("note-modal") as HTMLElement
  );
};

export default NoteModal;

For the modal to work, you need to add the portal element below the root element in the index.html file.


   <!-- Add the portal below the root div -->
   <div id="note-modal"></div>

At this point, I think it is the right time to add all the external CSS stylesheets to the index.html file. So, open the index.html file and replace its content with the following HTML code.

index.html


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
    <link
      href="https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css"
      rel="stylesheet"
    />
    <link
      href="https://unpkg.com/nprogress@0.2.0/nprogress.css"
      rel="stylesheet"
    />
  </head>
  <body>
    <div id="root"></div>
    <div id="note-modal"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

we added the stylesheets for the NProgress and Boxicons via the link tag in the head element.

Add the CRUD Functionalities

Now that you’ve created the reusable components, let’s create four React.js components to implement the CRUD operations against the backend API. Before proceeding with the CRUD functionalities, open your terminal and install the following dependencies.


yarn add zod react-toastify react-hook-form nprogress date-fns @tanstack/react-query @tanstack/react-query-devtools @hookform/resolvers && yarn add -D @types/nprogress 
# or
npm install zod react-toastify react-hook-form nprogress date-fns @tanstack/react-query @tanstack/react-query-devtools @hookform/resolvers && npm install -D @types/nprogress 

CREATE Operation

This component will provide an interface for creating new note items. We’ll leverage the createNoteFn() function we created with Axios to perform the CREATE operation of the CRUD pattern. To begin, go into the src/components directory and create a notes folder. Within the notes directory, create a create.note.tsx file and add the code snippet below.

src/components/notes/create.note.tsx


import { FC } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoadingButton } from "../LoadingButton";
import { toast } from "react-toastify";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createNoteFn } from "../../api/noteApi";
import NProgress from "nprogress";

type ICreateNoteProps = {
  setOpenNoteModal: (open: boolean) => void;
};

const createNoteSchema = object({
  title: string().min(1, "Title is required"),
  content: string().min(1, "Content is required"),
});

export type CreateNoteInput = TypeOf<typeof createNoteSchema>;

const CreateNote: FC<ICreateNoteProps> = ({ setOpenNoteModal }) => {
  const methods = useForm<CreateNoteInput>({
    resolver: zodResolver(createNoteSchema),
  });

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = methods;

  const queryClient = useQueryClient();

  const { mutate: createNote } = useMutation({
    mutationFn: (note: CreateNoteInput) => createNoteFn(note),
    onMutate() {
      NProgress.start();
    },
    onSuccess(data) {
      queryClient.invalidateQueries(["getNotes"]);
      setOpenNoteModal(false);
      NProgress.done();
      toast("Note created successfully", {
        type: "success",
        position: "top-right",
      });
    },
    onError(error: any) {
      setOpenNoteModal(false);
      NProgress.done();
      const resMessage =
        error.response.data.message ||
        error.response.data.detail ||
        error.message ||
        error.toString();
      toast(resMessage, {
        type: "error",
        position: "top-right",
      });
    },
  });

  const onSubmitHandler: SubmitHandler<CreateNoteInput> = async (data) => {
    createNote(data);
  };
  return (
    <section>
      <div className="flex justify-between items-center mb-3 pb-3 border-b border-gray-200">
        <h2 className="text-2xl text-ct-dark-600 font-semibold">Create Note</h2>
        <div
          onClick={() => setOpenNoteModal(false)}
          className="text-2xl text-gray-400 hover:bg-gray-200 hover:text-gray-900 rounded-lg p-1.5 ml-auto inline-flex items-center cursor-pointer"
        >
          <i className="bx bx-x"></i>
        </div>
      </div>
      <form className="w-full" onSubmit={handleSubmit(onSubmitHandler)}>
        <div className="mb-2">
          <label className="block text-gray-700 text-lg mb-2" htmlFor="title">
            Title
          </label>
          <input
            className={twMerge(
              `appearance-none border border-gray-400 rounded w-full py-3 px-3 text-gray-700 mb-2  leading-tight focus:outline-none`,
              `${errors["title"] && "border-red-500"}`
            )}
            {...methods.register("title")}
          />
          <p
            className={twMerge(
              `text-red-500 text-xs italic mb-2 invisible`,
              `${errors["title"] && "visible"}`
            )}
          >
            {errors["title"]?.message as string}
          </p>
        </div>
        <div className="mb-2">
          <label className="block text-gray-700 text-lg mb-2" htmlFor="title">
            Content
          </label>
          <textarea
            className={twMerge(
              `appearance-none border border-gray-400 rounded w-full py-3 px-3 text-gray-700 mb-2 leading-tight focus:outline-none`,
              `${errors.content && "border-red-500"}`
            )}
            rows={6}
            {...register("content")}
          />
          <p
            className={twMerge(
              `text-red-500 text-xs italic mb-2`,
              `${errors.content ? "visible" : "invisible"}`
            )}
          >
            {errors.content && errors.content.message}
          </p>
        </div>
        <LoadingButton loading={false}>Create Note</LoadingButton>
      </form>
    </section>
  );
};

export default CreateNote;

In the above, we created the form validation rules with the Zod schema validation library and assigned it the createNoteSchema variable. After that, we provided the defined schema to the useForm() hook of React-Hook-Form via the zodResolver() function.

Next, we created a React Query mutation hook to submit the form data to the backend API by evoking the createNoteFn() function. When the form is submitted and there aren’t any validation errors, the createNote() function will be evoked to make a POST request with the form data included in the request body to the /api/notes endpoint.

On successful mutation, the ["getNotes"] query will be invalidated which will cause React Query to fetch the most recent list of records from the database.

UPDATE Operation

Now that we’ve implemented the CREATE operation, let’s create a React component to handle the UPDATE operation. This component will be rendered into the DOM via the React portal we created above and it will have a form for updating the title or content of a note item.

The form validation will be handled by React-Hook-Form and the validation rules will be defined with the Zod schema validation library. So, navigate to the src/components/notes directory and create a update.note.tsx file. After that, open the newly-created update.note.tsx file and add the following code.

src/components/notes/update.note.tsx


import { FC, useEffect } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoadingButton } from "../LoadingButton";
import { toast } from "react-toastify";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { INote } from "../../api/types";
import { updateNoteFn } from "../../api/noteApi";

type IUpdateNoteProps = {
  note: INote;
  setOpenNoteModal: (open: boolean) => void;
};

const updateNoteSchema = object({
  title: string().min(1, "Title is required"),
  content: string().min(1, "Content is required"),
});

export type UpdateNoteInput = TypeOf<typeof updateNoteSchema>;

const UpdateNote: FC<IUpdateNoteProps> = ({ note, setOpenNoteModal }) => {
  const methods = useForm<UpdateNoteInput>({
    resolver: zodResolver(updateNoteSchema),
  });

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = methods;

  useEffect(() => {
    if (note) {
      methods.reset(note);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const queryClient = useQueryClient();
  const { mutate: updateNote } = useMutation({
    mutationFn: ({ noteId, note }: { noteId: string; note: UpdateNoteInput }) =>
      updateNoteFn(noteId, note),
    onSuccess(data) {
      queryClient.invalidateQueries(["getNotes"]);
      setOpenNoteModal(false);
      toast("Note updated successfully", {
        type: "success",
        position: "top-right",
      });
    },
    onError(error: any) {
      setOpenNoteModal(false);
      const resMessage =
        error.response.data.message ||
        error.response.data.detail ||
        error.message ||
        error.toString();
      toast(resMessage, {
        type: "error",
        position: "top-right",
      });
    },
  });

  const onSubmitHandler: SubmitHandler<UpdateNoteInput> = async (data) => {
    updateNote({ noteId: note.id, note: data });
  };
  return (
    <section>
      <div className="flex justify-between items-center mb-4">
        <h2 className="text-2xl text-ct-dark-600 font-semibold">Update Note</h2>
        <div
          onClick={() => setOpenNoteModal(false)}
          className="text-2xl text-gray-400 hover:bg-gray-200 hover:text-gray-900 rounded-lg p-1.5 ml-auto inline-flex items-center cursor-pointer"
        >
          <i className="bx bx-x"></i>
        </div>
      </div>{" "}
      <form className="w-full" onSubmit={handleSubmit(onSubmitHandler)}>
        <div className="mb-2">
          <label className="block text-gray-700 text-lg mb-2" htmlFor="title">
            Title
          </label>
          <input
            className={twMerge(
              `appearance-none border border-gray-400 rounded w-full py-3 px-3 text-gray-700 mb-2 leading-tight focus:outline-none`,
              `${errors["title"] && "border-red-500"}`
            )}
            {...methods.register("title")}
          />
          <p
            className={twMerge(
              `text-red-500 text-xs italic mb-2 invisible`,
              `${errors["title"] && "visible"}`
            )}
          >
            {errors["title"]?.message as string}
          </p>
        </div>
        <div className="mb-2">
          <label className="block text-gray-700 text-lg mb-2" htmlFor="title">
            Content
          </label>
          <textarea
            className={twMerge(
              `appearance-none border rounded w-full py-3 px-3 text-gray-700 mb-2 leading-tight focus:outline-none`,
              `${errors.content ? "border-red-500" : "border-gray-400"}`
            )}
            rows={6}
            {...register("content")}
          />
          <p
            className={twMerge(
              `text-red-500 text-xs italic mb-2`,
              `${errors.content ? "visible" : "invisible"}`
            )}
          >
            {errors.content && errors.content.message}
          </p>
        </div>
        <LoadingButton loading={false}>Update Note</LoadingButton>
      </form>
    </section>
  );
};

export default UpdateNote;

Above, we created a React Query mutation hook to fire a PATCH request to the backend API using the updateNoteFn() function we created with Axios. When the form is submitted and there are no errors, the onSubmitHandler() function will be called which will also evoke the updateNote() function to submit the form data to the API.

When the mutation resolves successfully, the ["getNotes"] query will be invalidated which will trigger React Query to retrieve the most current list of note items from the database.

On the other hand, if the mutation results in errors, alert notifications will be displayed in the UI to show the errors returned by the API.

DELETE Operation

Let’s create a component to perform the DELETE operation. This component will be used by a parent component to display the note details in the UI. To delete a note item in the database, we’ll create a React Query mutation hook that will make the DELETE request to the API by evoking the deleteNoteFn() function.

So, open the src/components/notes folder and create a note.component.tsx file within the notes directory. After that, add the code snippet below to the note.component.tsx file.

src/components/notes/note.component.tsx


import React, { FC, useEffect, useState } from "react";
import { format, parseISO } from "date-fns";
import { twMerge } from "tailwind-merge";
import NoteModal from "../note.modal";
import UpdateNote from "./update.note";
import { toast } from "react-toastify";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import NProgress from "nprogress";
import { INote } from "../../api/types";
import { deleteNoteFn } from "../../api/noteApi";

type NoteItemProps = {
  note: INote;
};

const NoteItem: FC<NoteItemProps> = ({ note }) => {
  const [openSettings, setOpenSettings] = useState(false);
  const [openNoteModal, setOpenNoteModal] = useState(false);

  useEffect(() => {
    const handleOutsideClick = (event: MouseEvent) => {
      const target = event.target as HTMLElement;
      const dropdown = document.getElementById(`settings-dropdown-${note.id}`);

      if (dropdown && !dropdown.contains(target)) {
        setOpenSettings(false);
      }
    };
    document.addEventListener("mousedown", handleOutsideClick);

    return () => {
      document.removeEventListener("mousedown", handleOutsideClick);
    };
  }, [note.id]);

  const queryClient = useQueryClient();
  const { mutate: deleteNote } = useMutation({
    mutationFn: (noteId: string) => deleteNoteFn(noteId),
    onMutate() {
      NProgress.start();
    },
    onSuccess(data) {
      queryClient.invalidateQueries(["getNotes"]);
      toast("Note deleted successfully", {
        type: "warning",
        position: "top-right",
      });
      NProgress.done();
    },
    onError(error: any) {
      const resMessage =
        error.response.data.message ||
        error.response.data.detail ||
        error.message ||
        error.toString();
      toast(resMessage, {
        type: "error",
        position: "top-right",
      });
      NProgress.done();
    },
  });

  const onDeleteHandler = (noteId: string) => {
    if (window.confirm("Are you sure")) {
      deleteNote(noteId);
    }
  };
  return (
    <>
      <div className="p-4 bg-white rounded-lg border border-gray-200 shadow-md flex flex-col justify-between overflow-hidden">
        <div className="details">
          <h4 className="mb-2 pb-2 text-2xl font-semibold tracking-tight text-gray-900">
            {note.title.length > 40
              ? note.title.substring(0, 40) + "..."
              : note.title}
          </h4>
          <p className="mb-3 font-normal text-ct-dark-200">
            {note.content.length > 210
              ? note.content.substring(0, 210) + "..."
              : note.content}
          </p>
        </div>
        <div className="relative border-t border-slate-300 flex justify-between items-center">
          <span className="text-ct-dark-100 text-sm">
            {format(parseISO(String(note.createdAt)), "PPP")}
          </span>
          <div
            onClick={() => setOpenSettings(!openSettings)}
            className="text-ct-dark-100 text-lg cursor-pointer"
          >
            <i className="bx bx-dots-horizontal-rounded"></i>
          </div>
          <div
            id={`settings-dropdown-${note.id}`}
            className={twMerge(
              `absolute right-0 bottom-3 z-10 w-28 text-base list-none bg-white rounded divide-y divide-gray-100 shadow`,
              `${openSettings ? "block" : "hidden"}`
            )}
          >
            <ul className="py-1" aria-labelledby="dropdownButton">
              <li
                onClick={() => {
                  setOpenSettings(false);
                  setOpenNoteModal(true);
                }}
                className="py-2 px-4 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
              >
                <i className="bx bx-pencil"></i> Edit
              </li>
              <li
                onClick={() => {
                  setOpenSettings(false);
                  onDeleteHandler(note.id);
                }}
                className="py-2 px-4 text-sm text-red-600 hover:bg-gray-100 cursor-pointer"
              >
                <i className="bx bx-trash"></i> Delete
              </li>
            </ul>
          </div>
        </div>
      </div>
      <NoteModal
        openNoteModal={openNoteModal}
        setOpenNoteModal={setOpenNoteModal}
      >
        <UpdateNote note={note} setOpenNoteModal={setOpenNoteModal} />
      </NoteModal>
    </>
  );
};

export default NoteItem;

When the API returns a 204 status code that indicates that the note item has been removed from the database, the getNotes query will be invalidated which will instruct React Query to fetch the most recent note items from the database.

After the list of note items minus the deleted one has been retrieved, React will re-render the DOM to display them in the UI.

Since a delete operation is irreversible, the user will be prompted to confirm his action before React Query will fire the DELETE request to the backend API.

READ Operation

The final CRUD functionality is READ which will occur in the src/App.tsx file. To fetch the list of note items from the database, we’ll utilise React Query’s useQuery() hook which will evoke the getNotesFn() function.

When the component mounts, the getNotes query will be fired to retrieve the records from the API and the data returned will be displayed in the UI by React.

src/App.tsx


import "react-toastify/dist/ReactToastify.css";
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useEffect, useState } from "react";
import { toast, ToastContainer } from "react-toastify";
import { getNotesFn } from "./api/noteApi";
import NoteModal from "./components/note.modal";
import CreateNote from "./components/notes/create.note";
import NoteItem from "./components/notes/note.component";
import NProgress from "nprogress";

function AppContent() {
  const [openNoteModal, setOpenNoteModal] = useState(false);

  const {
    data: notes,
    isLoading,
    isFetching,
  } = useQuery({
    queryKey: ["getNotes"],
    queryFn: () => getNotesFn(),
    staleTime: 5 * 1000,
    select: (data) => data.notes,
    onSuccess() {
      NProgress.done();
    },
    onError(error: any) {
      const resMessage =
        error.response.data.message ||
        error.response.data.detail ||
        error.message ||
        error.toString();
      toast(resMessage, {
        type: "error",
        position: "top-right",
      });
      NProgress.done();
    },
  });

  useEffect(() => {
    if (isLoading || isFetching) {
      NProgress.start();
    }
  }, [isLoading, isFetching]);

  return (
    <div className="2xl:max-w-[90rem] max-w-[68rem] mx-auto">
      <div className="m-8 grid grid-cols-[repeat(auto-fill,_320px)] gap-7 grid-rows-[1fr]">
        <div className="p-4 min-h-[18rem] bg-white rounded-lg border border-gray-200 shadow-md flex flex-col items-center justify-center">
          <div
            onClick={() => setOpenNoteModal(true)}
            className="flex items-center justify-center h-20 w-20 border-2 border-dashed border-ct-blue-600 rounded-full text-ct-blue-600 text-5xl cursor-pointer"
          >
            <i className="bx bx-plus"></i>
          </div>
          <h4
            onClick={() => setOpenNoteModal(true)}
            className="text-lg font-medium text-ct-blue-600 mt-5 cursor-pointer"
          >
            Add new note
          </h4>
        </div>
        {/* Note Items */}

        {notes?.map((note) => (
          <NoteItem key={note.id} note={note} />
        ))}

        {/* Create Note Modal */}
        <NoteModal
          openNoteModal={openNoteModal}
          setOpenNoteModal={setOpenNoteModal}
        >
          <CreateNote setOpenNoteModal={setOpenNoteModal} />
        </NoteModal>
      </div>
    </div>
  );
}

function App() {
  const [queryClient] = useState(() => new QueryClient());
  return (
    <>
      <QueryClientProvider client={queryClient}>
        <AppContent />
        <ToastContainer />
        <ReactQueryDevtools initialIsOpen={false} />
      </QueryClientProvider>
    </>
  );
}

export default App;

Testing the CRUD App

If you made it this far, I am proud of you. Now run yarn dev to start the Vite development server on port 3000. Once the server is ready and listening on port 3000, open http://localhost:3000/ in a new tab to see the React app.

Create New Note

To add a new note to the database, click on the plus (+) icon or the “Add new note” button to display the Create Note modal. Provide the title and content of the note and click on the “Create Note” button to submit the form data to the backend API.

Once the “Create Note” button is clicked, a loading spinner and a progress indicator will be displayed to indicate that the request is being processed by the API. After a few seconds, React will display the newly-created note item returned by the API in the UI.

react.js crud app create a new record with an api

Update a Note

To update an existing note item in the database, click on the three dots adjacent to the date element and click on the Edit button to display the Update Note modal. Modify the title or content and click on the Update Note button to submit the form data to the API.

After the API has updated the record that matches the ID provided in the request URL parameter in the database, the server will then return the updated version of the record to React.

Once the app receives a status code of 200, React Query will invalidate the “getNotes” query to fetch the most current note items from the database and provide the data returned by the API to React.

react.js crud app update an existing record against a restful api

Get All Notes

When you visit the root route http://localhost:3000/, React will make a READ operation to retrieve a paginated list of the note items from the database and display them in the UI.

react.js crud app retrieve all records in the database via a restful api

Delete a Note

To delete a note item from the database, click on the three dots opposite the date element and click on the Delete button.

React will prompt you to confirm your action. When you click on the Ok button, React Query will fire a mutation request to remove the record that matches that ID from the database.

If the request resolves in a 204 status code, React Query will invalidate the “getNotes” query again in order to fetch the most current data from the database. After that, the note item that was deleted will be removed from the UI.

react.js crud app delete a record in the database

Conclusion

So far, you’ve learned how to build a CRUD (create, read, update, and delete) application with React.js and communicate between a server and the React app via HTTP. If you found the article helpful, don’t hesitate to leave a comment below.

You can find the source code of the React.js CRUD app on GitHub.