In this comprehensive guide, you’ll build a React.js CRUD app using Redux Toolkit and RTK Query hooks. In brief, we’ll create RTK Query hooks that React will use to perform CRUD operations against a REST API.

What is RTK Query? RTK Query is a powerful server-state management library built on top of the Redux Toolkit core. It makes data fetching and caching in Redux projects a breeze and it also provides a powerful toolset to define API interface layers for an app.

To make the Redux Toolkit CRUD app interact with a RESTful API, I’ll provide step-by-step instructions in one of the sections to help you set up the backend API built with Django in minutes. I’ll also provide links for Deno, Node.js, and FastAPI.

More practice:

Build a CRUD App with React.js and Redux Toolkit

Prerequisites

This course assumes that you have:

  • Basic knowledge of React.js.
  • Basic understanding of API designs and CRUD architecture.
  • The latest or LTS version of Node.js installed on your machine.
  • Knowledge of Redux is strongly recommended since we will be using Redux Toolkit and RTK Query.

This course doesn’t require the following:

  • TypeScript knowledge assuming you are already comfortable with JavaScript.
  • Redux Toolkit and RTK Query knowledge, as they’ll be explained in this course.

Run the React Redux Toolkit App Locally

  • Download the latest version of Node.js from https://nodejs.org/ and install the Yarn package manager globally with npm i -g yarn.
  • Download or clone the Redux Toolkit CRUD project from https://github.com/wpcodevo/react-rtkquery-crud-app and open it with an IDE.
  • Open the integrated terminal in the root directory and run yarn or yarn install to install the project’s dependencies.
  • In the console of the root directory, run yarn dev to start the Vite dev server
  • Visit http://localhost:3000/ in a new tab to see the Redux Toolkit CRUD app. Once the page has fully loaded, perform the CRUD operations against the Django API by interacting with the elements on the page.

    Note: When you open the React app on http://127.0.0.1:3000 , you might get a site can’t be reached or a CORS error. This is because the Django server is configured to accept requests from only http://localhost:3000/.

Run a Django API with the React App

For a complete guide on how to build the Django CRUD API see the post Build CRUD API with Django REST framework. However, you can follow these steps to quickly spin up the Django API.

  • Navigate to https://www.python.org/downloads/ to install Python.
  • Open https://github.com/wpcodevo/Django_Crud_Project and download or clone the Django CRUD API project. After that, open the source code in an IDE or text editor.
  • Execute this command in the console of the root directory to create a virtual environment.
    • Windows OSpython -m venv venv
    • Mac or Linux OSpython3 -m venv venv
  • After the new virtual environment has been generated, you might be prompted by your IDE to activate it in the current workspace folder. Click “Yes” to activate it.
    click on Yes to activate the python virtual environment
    Note: To activate the virtual environment in the terminal, close the previously opened terminal and open a new one.

    If for any reason your IDE or text editor didn’t prompt you to activate the new virtual environment, execute the command below in the console of the root directory 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
  • Install the project’s dependencies with pip install -r requirements.txt
  • Now run python manage.py migrate to migrate the database schema to the SQLite database.
  • Start the Django dev server by running python manage.py runserver
  • Interact with the Django API from the React app or open any API testing tool like Postman or Thunder Client VS Code extension to test the endpoints.

You can find a step-by-step implementation of the CRUD API in Node.js, Deno, and FastAPI from the following tutorials.

Setup the React Project with Tailwind CSS

Typically, we generate a React.js project with Create React App, but the set-up process can take an enormous amount of time since over 140 MB of dependencies has to be installed first. Create React App works well for small projects but as the project size increases, its performance decreases.

Vite on the other hand addresses these issues by leveraging ES Modules since all modern browsers have support for ES Modules. Also, the Vite scaffolding tool only installs 31 MB of dependencies to bootstrap a range of project types. At the time of writing this article, Vite supports React, PreactVueLitSvelte, and even vanilla JavaScript.

Now let’s come back to the main focus of this section. We’ll bootstrap a React.js project with the Vite scaffolding tool and configure the project to use Tailwind CSS for styling.

Scaffold the React Project

First things first, open a terminal in your desktop directory or any convenient location on your machine and run this command to set up the React project.


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

This will create a directory named react-rtkquery-crud-app and install the vite-create binary from the NPM repository. After that, you will be prompted to select the type of framework. Since we are dealing with Redux Toolkit, select React as the framework type and choose TypeScript as the variant.

Once the project has been generated, run yarn or yarn install to install all the required dependencies. After the installation is complete, open the project in an IDE or text editor.

Now open the package.json file and replace the dev script with:

package.json


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

This will instruct Vite to start the dev server on port 3000 instead of the default port 5173.

Add Tailwind CSS

Now let’s add Tailwind CSS to the project. To do that, install the tailwind CSS library and its peer dependencies:


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

Generate the postcss.config.cjs and tailwind.config.cjs files with this command:


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

Open the newly-created tailwind.config.cjs file and replace its content with the following tailwind CSS 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": "#ff531a",
        "ct-blue-700": "#e63900",
        "ct-yellow-600": "#f9d13e",
      },
      fontFamily: {
        Poppins: ["Poppins, sans-serif"],
      },
      container: {
        center: true,
        padding: "1rem",
        screens: {
          lg: "1125px",
          xl: "1125px",
          "2xl": "1125px",
          "3xl": "1500px",
        },
      },
    },
  },
  plugins: [],
};

We added Poppins font to the theme.fontFamily section, defined some custom colors, and provided paths to the template files.

Now open the src/index.css file and replace its content with the following tailwind CSS directives, Poppins font import, and global 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: #80ffff !important;
    height: 3px !important;
}

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

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

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

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

body{
    background-color: #ff6633;
}

Create the CRUD API Slice with RTK Query

Redux Toolkit comes with RTK Query that can be used to create API slices to handle fetch requests and caching. We’ll utilize RTK Query to create an API slice that has five hooks. These hooks will allow us to perform the CRUD operations by making HTTP requests to the CRUD endpoints on a backend API.

To begin, open a terminal in the root folder and install these dependencies:


yarn add @reduxjs/toolkit react-redux nprogress && yarn add -D @types/node @types/nprogress
# or
npm install @reduxjs/toolkit react-redux nprogress && npm install -D @types/node @types/nprogress

Since we chose TypeScript as the variant in the project scaffolding process, let’s define some TypeScript types to help us type the API responses and requests. To do this, go into the src directory and create a redux folder. After that, create a types.ts file in the redux folder and add the following types.

src/redux/types.ts


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

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, let’s define the API slice to describe how RTK Query should retrieve and send data to the endpoints on the backend API. As a rule of thumb, the Redux Toolkit team recommends we create one API slice per base URL.

To give visual feedback to the user when a request is in flight, we’ll use the nprogress library to display a thin progress bar to indicate that the request is been processed by the backend API.

Create a noteAPI.ts file in the src/redux directory and add the following RTK Query hooks. Typically, we create the API slices in an api folder but since this project is small, we can skip that step.

src/redux/noteAPI.ts


import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { IMutateNote, INote, INoteResponse } from "./types";
import NProgress from "nprogress";

const BASEURL = "http://localhost:8000/api/notes";

export const noteAPI = createApi({
  reducerPath: "noteAPI",
  baseQuery: fetchBaseQuery({ baseUrl: BASEURL }),
  tagTypes: ["Notes"],
  endpoints: (builder) => ({
    createNote: builder.mutation<INoteResponse, IMutateNote>({
      query(note) {
        return {
          url: "/",
          method: "POST",
          credentials: "include",
          body: note,
        };
      },
      invalidatesTags: [{ type: "Notes", id: "LIST" }],
      transformResponse: (result: { note: INoteResponse }) => result.note,
      onQueryStarted(arg, api) {
        NProgress.start();
      },
    }),
    updateNote: builder.mutation<
      INoteResponse,
      { id: string; note: IMutateNote }
    >({
      query({ id, note }) {
        return {
          url: `/${id}`,
          method: "PATCH",
          credentials: "include",
          body: note,
        };
      },
      invalidatesTags: (result, error, { id }) =>
        result
          ? [
              { type: "Notes", id },
              { type: "Notes", id: "LIST" },
            ]
          : [{ type: "Notes", id: "LIST" }],
      transformResponse: (response: { note: INoteResponse }) => response.note,
      onQueryStarted(arg, api) {
        NProgress.start();
      },
    }),
    getNote: builder.query<INoteResponse, string>({
      query(id) {
        return {
          url: `/${id}`,
          credentials: "include",
        };
      },
      providesTags: (result, error, id) => [{ type: "Notes", id }],
    }),
    getAllNotes: builder.query<INote[], { page: number; limit: number }>({
      query({ page, limit }) {
        return {
          url: `/?page=${page}&limit=${limit}`,
          credentials: "include",
        };
      },
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({
                type: "Notes" as const,
                id,
              })),
              { type: "Notes", id: "LIST" },
            ]
          : [{ type: "Notes", id: "LIST" }],
      transformResponse: (results: { notes: INote[] }) => results.notes,
      onQueryStarted(arg, api) {
        NProgress.start();
      },
      keepUnusedDataFor: 5,
    }),
    deleteNote: builder.mutation<INoteResponse, string>({
      query(id) {
        return {
          url: `/${id}`,
          method: "DELETE",
          credentials: "include",
        };
      },
      invalidatesTags: [{ type: "Notes", id: "LIST" }],
      onQueryStarted(arg, api) {
        NProgress.start();
      },
    }),
  }),
});

export const {
  useCreateNoteMutation,
  useDeleteNoteMutation,
  useUpdateNoteMutation,
  useGetAllNotesQuery,
} = noteAPI;
  • useCreateNoteMutation – This hook will fire an HTTP POST request to add a new note item to the database.
  • useDeleteNoteMutation – This hook will find a note item by ID and instruct the API to delete the found record from the database.
  • useUpdateNoteMutation – This hook will find a note item by ID and update its fields based on the data provided in the request body.
  • useGetAllNotesQuery – This hook will retrieve a paginated list of note items from the backend API.

Add the CRUD API Slice to the Redux Store

Now let’s create the Redux store and register the API slice using setupListeners() and configureStore() utility functions. The API slice contains an auto-generated Redux slice reducer and a custom middleware that manages subscription lifetimes. Both of these need to be added to the Redux store:

src/redux/store.ts


import { configureStore } from "@reduxjs/toolkit";
import { setupListeners } from "@reduxjs/toolkit/query";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import { noteAPI } from "./noteAPI";

export const store = configureStore({
  reducer: {
    [noteAPI.reducerPath]: noteAPI.reducer,
  },
  devTools: process.env.NODE_ENV === "development",
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({}).concat([noteAPI.middleware]),
});

setupListeners(store.dispatch);

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

The configureStore() function does a lot behind the scene — it automatically connects the app to Redux DevTools and adds redux-thunk middleware by default to handle asynchronous operations.

To enable refetchOnFocus and refetchOnReconnect behaviors in the app, we passed the dispatch method available on the store to the setupListeners() function.

Create Reusable React Components

In this section, you’ll create reusable components and style them with tailwind CSS. To begin, install the tailwind-merge library that will allow us to efficiently merge Tailwind CSS classes in JS without style conflicts.


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

Now let’s create a Spinner component. Go into the src directory and create a components folder. Within the src/components folder, create a Spinner.tsx file and add the following code snippets.

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;

Here, you’ll create a button that will display the Spinner component we created above. Create a LoadingButton.tsx file in the src/components folder 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>
  );
};

Next, let’s create a modal component with React portals. Using React portals will allow us to render the popup component outside the React hierarchy tree without comprising the parent-child relationship between components.

So, create a note.modal.tsx file in the src/components directory and paste the code below into the file.

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;

Now let’s create an element for the portal. Open the index.html file and create a div with a note-modal ID below the root div. React will render the content of the modal into this div when the modal is active.


<!-- Below the root div -->
<div id="note-modal"></div>

At this point, let’s add the icon and nprogress external stylesheets to the index.html file. To do that, open the index.html file and replace its content with the following markup.

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>

Implement the CRUD Functionalities

Now that we have the RTK Query hooks and reusable components, let’s create four React components to implement the CRUD functionalities. But before that, open your terminal and install these dependencies.


yarn add date-fns react-toastify react-hook-form zod @hookform/resolvers 
# or
npm install date-fns react-toastify react-hook-form zod @hookform/resolvers

CREATE Operation

Now that you’ve installed the required dependencies, let’s write the logic for the CREATE functionality. This component will have a form with title and content input fields. The form validation will be handled by React-Hook-Form and the validation rules will be defined with the Zod library.

The validation schema created with Zod will be passed to React-Hook-Form’s useForm() hook via the resolver property. Using our own validation schema will give us absolute control over the validation and how error messages are displayed on the form.

To perform the CREATE operation, we’ll leverage the useCreateNoteMutation() hook derived from the RTK Query API slice. So, when the form is submitted and there are no validation errors, the onSubmitHandler() method will be called which will in turn evoke the createNote() mutation to submit the form data to the backend API.

src/components/notes/create.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 NProgress from "nprogress";
import { useCreateNoteMutation } from "../../redux/noteAPI";

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 [createNote, { isLoading, isError, error, isSuccess }] =
    useCreateNoteMutation();

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

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

  useEffect(() => {
    if (isSuccess) {
      setOpenNoteModal(false);
      toast.success("Note created successfully");
      NProgress.done();
    }

    if (isError) {
      setOpenNoteModal(false);
      NProgress.done();
      const err = error as any;
      if (Array.isArray(err.data.error)) {
        err.data.error.forEach((el: any) =>
          toast.error(el.message, {
            position: "top-right",
          })
        );
      } else {
        const resMessage =
          err.data.message || err.data.detail || err.message || err.toString();
        toast.error(resMessage, {
          position: "top-right",
        });
      }
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoading]);

  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;

After the createNote() mutation has been evoked, RTK Query will add the form data to the request body and fire a POST request to the /api/notes endpoint. Once the request is in flight, the NProgress.start() function will be called to display a thin progress bar indicating that the request is being processed by the backend API.

If the mutation resolves successfully, the server-state cache will be invalidated which will cause RTK Query to re-fetch the most current list of items from the API.

After RTK Query has updated the cache with the list of items, React will re-render the DOM to display the newly-created item in the UI.

UPDATE Operation

Now let’s create a component to handle the UPDATE operation. This component will have a form for updating the fields of an item in the database. The form will have a title and content input fields.

To edit the fields of a note item, we’ll utilize the useUpdateNoteMutation() hook derived from the RTK Query API slice. When the form is submitted and is valid, the updateNote() mutation will be evoked to submit the form data to the backend API.

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 NProgress from "nprogress";
import { INote } from "../../redux/types";
import { useUpdateNoteMutation } from "../../redux/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 [updateNote, { isLoading, isError, error, isSuccess }] =
    useUpdateNoteMutation();

  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
  }, []);

  useEffect(() => {
    if (isSuccess) {
      setOpenNoteModal(false);
      toast.success("Note updated successfully");
      NProgress.done();
    }

    if (isError) {
      setOpenNoteModal(false);
      const err = error as any;
      const resMessage =
        err.data.message || err.data.detail || err.message || err.toString();
      toast.error(resMessage, {
        position: "top-right",
      });
      NProgress.done();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoading]);

  const onSubmitHandler: SubmitHandler<UpdateNoteInput> = async (data) => {
    updateNote({ id: 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;

RTK Query will add the form data to the request body and append the item’s ID to the request URL before making the PATCH request to the /api/notes/:noteId endpoint.

Once the mutation resolves successfully, the cache belonging to the updated item will be invalidated which will cause RTK Query to fetch the newly-updated item from the API.

After the cache has been updated, React will re-render the DOM to reflect the changes that were made in the note item.

DELETE Operation

Here, let’s implement the DELETE functionality. To do this, we’ll create a note item component which will have buttons for triggering the UPDATE and DELETE operations.

To delete a note item in the database, we’ll leverage the useDeleteNoteMutation() hook derived from the noteAPI slice. When the Delete button is clicked, we’ll prompt the user to confirm the action or cancel it. If the user confirms his action, the deleteNote() mutation will be evoked.

src/components/notes/note.component.tsx


import { 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 NProgress from "nprogress";
import { INote } from "../../redux/types";
import { useDeleteNoteMutation } from "../../redux/noteAPI";

type NoteItemProps = {
  note: INote;
};

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

  const [deleteNote, { isLoading, isError, error, isSuccess }] =
    useDeleteNoteMutation();

  useEffect(() => {
    if (isSuccess) {
      setOpenNoteModal(false);
      toast.warning("Note deleted successfully");
      NProgress.done();
    }

    if (isError) {
      setOpenNoteModal(false);
      const err = error as any;
      const resMessage =
        err.data.message || err.data.detail || err.message || err.toString();
      toast.error(resMessage, {
        position: "top-right",
      });
      NProgress.done();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoading]);

  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 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;

RTK Query will then append the ID of the item to be deleted to the request URL and make a DELETE request to the /api/notes/:noteId endpoint. If the mutation succeeds, the cache will be invalidated which will make RTK Query fetch the most current list of note items from the database. After that, React will re-render the DOM to remove the deleted item from the UI.

READ Operation

The last CRUD operation to implement is the READ functionality. To do this, we’ll use the useGetAllNotesQuery() hook derived from the noteAPI slice. This hook will be evoked to fetch a paginated list of note items from the API when the component mounts. In addition, the hook will re-run when the window gains focus or the internet is reconnected.

src/App.tsx


import "react-toastify/dist/ReactToastify.css";
import { useEffect, useState } from "react";
import { toast, ToastContainer } from "react-toastify";
import NoteModal from "./components/note.modal";
import CreateNote from "./components/notes/create.note";
import NoteItem from "./components/notes/note.component";
import NProgress from "nprogress";
import { Provider } from "react-redux";
import { store } from "./redux/store";
import { useGetAllNotesQuery } from "./redux/noteAPI";

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

  const {
    isLoading,
    isFetching,
    isError,
    isSuccess,
    error,
    data: notes,
  } = useGetAllNotesQuery(
    { page: 1, limit: 10 },
    { refetchOnFocus: true, refetchOnReconnect: true }
  );

  const loading = isLoading || isFetching;

  useEffect(() => {
    if (isSuccess) {
      NProgress.done();
    }

    if (isError) {
      setOpenNoteModal(false);
      const err = error as any;
      const resMessage =
        err.data.message || err.data.detail || err.message || err.toString();
      toast.error(resMessage, {
        position: "top-right",
      });
      NProgress.done();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [loading]);

  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() {
  return (
    <>
      <Provider store={store}>
        <AppContent />
        <ToastContainer />
      </Provider>
    </>
  );
}

export default App;

Test the React CRUD App

Now that we have created the CRUD components, let’s start the Vite dev server and test the React CRUD app with the backend API. So, run yarn dev to start the development server.

Perform the CREATE Functionality

To add a new note item to the database, click on the plus (+) icon or the “Add new note” button to display the “Create Note” modal popup. Enter the title and content and click the Create Note button to submit the form.

Once the form has been submitted, React-Hook-Form will validate the fields against the rules defined in the Zod schema. If the form is valid, the useCreateNoteMutation() hook will be evoked to submit the form data to the backend API.

redux toolkit and rtk query crud app create new note item

The backend API will then validate the request body upon receiving the request, add the note item to the database, and return the newly-created record back to the React app.

If the mutation resolves successfully, React will re-render the DOM to display the note item in the UI. Otherwise, an alert notification will be displayed to list the errors returned by the API.

Perform the UPDATE Functionality

To edit a note item in the database, click on the three dots (…) next to the date element and select the Edit option to display the Update Note modal. On the Update Note popup, edit the title or content and click the Update Note button to submit the form.

If the form is valid, the useUpdateNoteMutation() hook will be evoked to submit modified data to the backend API. The backend API will then query the database to find the record that matches the provided ID and update its fields based on the data provided in the request body.

redux toolkit and rtk query crud app edit existing note item

Once the mutation is successful, React will re-render the DOM to reflect the changes.

Perform the READ Functionality

When you visit the root route of the app, RTK Query will make a GET request to retrieve a paginated list of note items from the API. On successful query, React will re-render the DOM to display the list of note items in the UI.

redux toolkit and rtk query crud app get all note items

Perform the DELETE Functionality

To delete a note item in the database, click on the three dots again and choose the Delete option. Because DELETE operations are expensive, you’ll be prompted to confirm your action before the useDeleteNoteMutation() hook will be called.

When you click on the ok button, RTK Query will fire a DELETE request to the backend API. The API will then find the record and delete it from the database.

On successful mutation, the note item will be removed from the DOM which will reflect in the UI.

redux toolkit and rtk query crud app delete note item

Conclusion

Congrats on reaching the end. In this article, you learned how to create a React.js CRUD app with Redux Toolkit and RTK Query hooks.

You can find the complete source code of the React Redux Toolkit project on GitHub