tRPC popularly known as t3-stack is a toolkit for building and consuming end-to-end typesafe APIs without depending on defined schemas or extra libraries for code generation.

This article will teach you how to build a full-stack tRPC CRUD app to perform the Create/Update/Get/Delete operations with React.js, Express, and Node.js using a monolithic repository aka monorepo.

tRPC API with React.js, Express, and Node.js Series:

  1. Build tRPC API with React.js, Node.js & MongoDB: Project Setup
  2. Build tRPC API with React.js & Node.js: Access and Refresh Tokens
  3. Full-Stack App tRPC, React.js, & Node.js: JWT Authentication
  4. Build Full-Stack tRPC CRUD Application with Node.js, and React.js

Read more articles:

Build Full-Stack tRPC CRUD Application with Node.js, and React.js

What is tRPC?

tRPC is a library introduced to solve the complexities of GraphQL and to streamline the process of building type-safety full-stack applications with TypeScript.

tRPC is a lot simpler and couples your server and client more tightly together by allowing you to easily share the TypeScript types between them.

Prerequisites

Before we start, you should:

  • be comfortable with JavaScript, TypeScript, and React.js
  • be comfortable with CSS and tailwindCss

React Query, tRPC Client and Server Overview

We will build a React.js, tailwindCss, and TypeScript client with React Query and tRPC Client to make CRUD operations against a tRPC API.

Below are the endpoints of the tRPC CRUD API:

ROUTEDESCRIPTION
/api/trpc/posts.getPostsRetrieve all posts
/api/trpc/posts.createCreate new post
/api/trpc/posts.getPostGet a single post
/api/trpc/posts.updateUpdate a post
/api/trpc/posts.deleteDelete a post

-A tRPC React Query GET request is made on the homepage to retrieve all posts

tRPC fullstack crud app fetch all post query 1

-To add a new post to the database, click on the “Create Post” button on the navigation menu to display the create post popup.

Next, provide the required information and make a tRPC POST request to the tRPC server for the post to be added to the database.

tRPC fullstack crud app create post mutation

-To update a post, click on the three dots adjacent to the author’s name and then click the edit button to display the update post popup.

tRPC fullstack crud app edit post overview

Here the fields will be automatically filled when the popup mounts. Edit the fields you want and make a tRPC POST request to update the post on the tRPC server.

tRPC fullstack crud app update post mutation

-To remove a post from the database, click on the three dots again and then click the delete button. You will be prompted to confirm your action before a tRPC POST request is made to the tRPC server to delete the post from the database.

tRPC fullstack crud app delete post mutation

Follow these articles to implement the tRPC server and the authentication aspect before continuing with this tutorial.

tRPC Client and React Query Project Setup

Follow the tRPC Project Setup article to set up the tRPC client and server with React, Node.js, Express, MongoDB, and tailwindCss before continuing with this tutorial.

After the project setup, change the directory into the client folder cd packages/client and run the following code to install the AppRouter type we exported from the tRPC server.


yarn add server@1.0.0

Where:

  • server – is the name used in the packages/server/package.json file.
  • @1.0.0 – is the version of the packages/server/package.json file.

Creating the TypeScript Types

Let’s create TypeScript types to help us type the tRPC API responses. So, navigate into the packages/client/src directory and create a lib folder. Within the lib folder, create a types.ts file and add the following code.

packages/client/src/lib/types.ts


export interface IUser {
  name: string;
  email: string;
  role: string;
  photo: string;
  _id: string;
  id: string;
  createdAt: string;
  updatedAt: string;
  __v: number;
}

export type IPost = {
  _id: string;
  id: string;
  title: string;
  content: string;
  category: string;
  image: string;
  createdAt: string;
  updatedAt: string;
  user: {
    email: string;
    name: string;
    photo: string;
  };
};

Create a Modal Component with TailwindCSS

Here, let’s create a reusable modal component with React Portals. We could have used z-Index and some lines of CSS to render the popup above all the elements in the DOM hierarchy but this approach disrupts the parent-child relationship in the DOM hierarchy.

Create a modals folder in the packages/client/src/components directory and create a post.modal.tsx file in the modals folder. After that, open the post.modal.tsx file and add the following TSX code.

packages/client/src/components/modals/post.modal.tsx


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

type IPostModal = {
  openPostModal: boolean;
  setOpenPostModal: (openPostModal: boolean) => void;
  children: React.ReactNode;
};

const PostModal: FC<IPostModal> = ({
  openPostModal,
  setOpenPostModal,
  children,
}) => {
  if (!openPostModal) return null;
  return ReactDom.createPortal(
    <>
      <div
        className='fixed inset-0 bg-[rgba(0,0,0,.5)] z-[1000]'
        onClick={() => setOpenPostModal(false)}
      ></div>
      <div className='max-w-lg w-full rounded-md fixed top-[15%] left-1/2 -translate-x-1/2 bg-white z-[1001] p-6'>
        {children}
      </div>
    </>,
    document.getElementById('post-modal') as HTMLElement
  );
};

export default PostModal;

Next, add the React Portal element as a sibling element to the root div in thepackages/client/public/index.html file. React will render the content of the modal popup in the portal when the modal is active.


<div id="post-modal"></div>

After adding the React portal element, you should end up with an HTML code that looks somewhat like this:

tRPC edit the html file

tRPC Client and React Query DELETE Request

Now change the directory into the packages/client folder in your terminal and run this command to install the date-fns library. This package will help us format the createdAt and updatedAt timestamps returned by the tRPC server.


yarn add date-fns

date-fns – is a library that provides utility functions for manipulating JavaScript dates in the browser and Node.js.

Let’s create a React component to implement the DELETE operation. When the deletePost mutation hook is triggered, the deletePost tRPC procedure will be evoked to delete the record that matches the ID.

packages/client/src/components/posts/post.component.tsx


import React, { FC, useEffect, useState } from "react";
import { format, parseISO } from "date-fns";
import { twMerge } from "tailwind-merge";
import { IPost } from "../../lib/types";
import { toast } from "react-toastify";
import useStore from "../../store";
import PostModal from "../modals/post.modal";
import UpdatePost from "./update.post";
import { trpc } from "../../trpc";
import { useQueryClient } from "@tanstack/react-query";

type PostItemProps = {
  post: IPost;
};

const PostItem: FC<PostItemProps> = ({ post }) => {
  const [openMenu, setOpenMenu] = useState(false);
  const [openPostModal, setOpenPostModal] = useState(false);
  const store = useStore();
  const queryClient = useQueryClient();
  const { isLoading, mutate: deletePost } = trpc.deletePost.useMutation({
    onSuccess(data) {
      store.setPageLoading(false);
      queryClient.refetchQueries([["getPosts"]]);
      toast("Post deleted successfully", {
        type: "success",
        position: "top-right",
      });
    },
    onError(error: any) {
      store.setPageLoading(false);
      error.response.errors.forEach((err: any) => {
        toast(err.message, {
          type: "error",
          position: "top-right",
        });
      });
    },
  });

  useEffect(() => {
    if (isLoading) {
      store.setPageLoading(true);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoading]);

  const toggleMenu = () => {
    setOpenMenu(!openMenu);
  };

  const onDeleteHandler = (id: string) => {
    toggleMenu();
    if (window.confirm("Are you sure")) {
      deletePost({ postId: id });
    }
  };
  return (
    <>
      <div
        className="rounded-md shadow-md bg-white"
        onClick={() => toggleMenu()}
      >
        <div className="mx-2 mt-2 overflow-hidden rounded-md">
          <img
            src={post.image}
            alt={post.title}
            className="object-fill w-full h-full"
          />
        </div>
        <div className="p-4">
          <h5 className="font-semibold text-xl text-[#4d4d4d] mb-4">
            {post.title.length > 25
              ? post.title.substring(0, 25) + "..."
              : post.title}
          </h5>
          <div className="flex items-center mt-4">
            <p className="p-1 rounded-sm mr-4 bg-[#dad8d8]">{post.category}</p>
            <p className="text-[#ffa238]">
              {format(parseISO(post.createdAt), "PPP")}
            </p>
          </div>
        </div>
        <div className="flex justify-between items-center px-4 pb-4">
          <div className="flex items-center">
            <div className="w-12 h-12 rounded-full overflow-hidden">
              <img
                src={post.user.photo}
                alt={post.user.name}
                className="object-cover w-full h-full"
              />
            </div>
            <p className="ml-4 text-sm font-semibold">{post.user.name}</p>
          </div>
          <div className="relative">
            <div
              className="text-3xl text-[#4d4d4d] cursor-pointer p-3"
              onClick={toggleMenu}
            >
              <i className="bx bx-dots-horizontal-rounded"></i>
            </div>
            <ul
              className={twMerge(
                `absolute bottom-5 -right-1 z-50 py-2 rounded-sm bg-white shadow-lg transition ease-out duration-300 invisible`,
                `${openMenu ? "visible" : "invisible"}`
              )}
            >
              <li
                className="w-24 h-7 py-3 px-2 hover:bg-[#f5f5f5] flex items-center gap-2 cursor-pointer transition ease-in duration-300"
                onClick={() => {
                  setOpenPostModal(true);
                  toggleMenu();
                }}
              >
                <i className="bx bx-edit-alt"></i> <span>Edit</span>
              </li>
              <li
                className="w-24 h-7 py-3 px-2 hover:bg-[#f5f5f5] flex items-center gap-2 cursor-pointer transition ease-in duration-300"
                onClick={() => onDeleteHandler(post._id)}
              >
                <i className="bx bx-trash"></i> <span>Delete</span>
              </li>
            </ul>
          </div>
        </div>
      </div>
      <PostModal
        openPostModal={openPostModal}
        setOpenPostModal={setOpenPostModal}
      >
        <UpdatePost post={post} setOpenPostModal={setOpenPostModal} />
      </PostModal>
    </>
  );
};

export default PostItem;

On a successful mutation, a success alert notification message will be displayed and the [["getPosts"]] query will be invalidated. This will cause React Query to request the most current data from the tRPC API. Otherwise, an error notification will be displayed to indicate the error message sent by the tRPC API.

tRPC Client and React Query GET Request

Here, let’s create a component to perform the READ functionality of CRUD. Before that, let’s create a Message component that will be displayed when there are no records in the database. So, create a Message.tsx file in the packages/client/src/components folder and add the following TSX code.

packages/client/src/components/Message.tsx


import React, { FC } from 'react';

type IMessageProps = {
  children: React.ReactNode;
};
const Message: FC<IMessageProps> = ({ children }) => {
  return (
    <div
      className='max-w-3xl mx-auto rounded-lg px-4 py-3 shadow-md bg-teal-100 flex items-center justify-center h-40'
      role='alert'
    >
      <span className='text-teal-500 text-xl font-semibold'>{children}</span>
    </div>
  );
};

export default Message;

With that out of the way, let’s create the component to fetch a list of records from the tRPC API and display them in the UI. Go into the packages/client/src/pages folder and create a home.page.tsx file. Then open the newly-created home.page.tsx file and add the following code.

packages/client/src/pages/home.page.tsx


import { useEffect } from "react";
import { useCookies } from "react-cookie";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import Message from "../components/Message";
import PostItem from "../components/posts/post.component";
import useStore from "../store";
import { trpc } from "../trpc";

const HomePage = () => {
  const [cookies] = useCookies(["logged_in"]);
  const store = useStore();
  const navigate = useNavigate();
  const { data: posts } = trpc.getPosts.useQuery(
    { limit: 10, page: 1 },
    {
      select: (data) => data.data.posts,
      retry: 1,
      onSuccess: (data) => {
        store.setPageLoading(false);
      },
      onError(error: any) {
        store.setPageLoading(false);
        toast(error.message, {
          type: "error",
          position: "top-right",
        });
      },
    }
  );

  useEffect(() => {
    if (!cookies.logged_in) {
      navigate("/login");
    }
  }, [cookies.logged_in, navigate]);

  return (
    <>
      <section className="bg-ct-blue-600 min-h-screen py-12">
        <div>
          {posts?.length === 0 ? (
            <Message>There are no posts at the moment</Message>
          ) : (
            <div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-5 px-6">
              {posts?.map((post: any) => (
                <PostItem key={post._id} post={post} />
              ))}
            </div>
          )}
        </div>
      </section>
    </>
  );
};

export default HomePage;

When this component mounts, the getPosts query hook will be triggered to fetch the first 10 records from the tRPC API. On successful query, React Query will update the server state with the list of records and React will re-render the DOM to display them in the UI. On error, an alert notification will be displayed to show the error sent by the tRPC API.

tRPC and React Query CREATE Request

We are now ready to implement the CREATE operation of CRUD. This component will contain a form that has a title, category, content, and image fields. The validation schema will be created with Zod and the form validation will be handled by React-Hook-Form.

When the form is submitted and there are no validation errors, the onSubmitHandler() function will be evoked by React-Hook-Form which will in turn trigger the createPost mutation hook to submit the form data to the tRPC API.

If the tRPC API adds the new record to the database and returns a successful response, a success alert notification will be displayed and the .refetchQueries([["getPosts"]]) method will be evoked to re-fetch the latest list of records from the tRPC API.

packages/client/src/components/posts/create.post.tsx


import { FC, useEffect } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import FileUpLoader from "../FileUpload";
import { LoadingButton } from "../LoadingButton";
import TextInput from "../TextInput";
import { toast } from "react-toastify";
import useStore from "../../store";
import { trpc } from "../../trpc";
import { useQueryClient } from "@tanstack/react-query";

const createPostSchema = object({
  title: string().min(1, "Title is required"),
  category: string().min(1, "Category is required"),
  content: string().min(1, "Content is required"),
  image: string().min(1, "Image is required"),
});

type CreatePostInput = TypeOf<typeof createPostSchema>;

type ICreatePostProp = {
  setOpenPostModal: (openPostModal: boolean) => void;
};

const CreatePost: FC<ICreatePostProp> = ({ setOpenPostModal }) => {
  const store = useStore();
  const queryClient = useQueryClient();
  const { isLoading, mutate: createPost } = trpc.createPost.useMutation({
    onSuccess(data) {
      store.setPageLoading(false);
      setOpenPostModal(false);
      queryClient.refetchQueries([["getPosts"]]);
      toast("Post created successfully", {
        type: "success",
        position: "top-right",
      });
    },
    onError(error: any) {
      store.setPageLoading(false);
      setOpenPostModal(false);
       toast(error.message, {
          type: "error",
          position: "top-right",
        });
    },
  });
  const methods = useForm<CreatePostInput>({
    resolver: zodResolver(createPostSchema),
  });

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

  useEffect(() => {
    if (isLoading) {
      store.setPageLoading(true);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoading]);

  const onSubmitHandler: SubmitHandler<CreatePostInput> = async (data) => {
    createPost(data);
  };
  return (
    <section>
      <h2 className="text-2xl font-semibold mb-4">Create Post</h2>

      <FormProvider {...methods}>
        <form className="w-full" onSubmit={handleSubmit(onSubmitHandler)}>
          <TextInput name="title" label="Title" />
          <TextInput name="category" label="Category" />
          <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-ct-dark-200 rounded w-full py-3 px-3 text-gray-700 mb-2 leading-tight focus:outline-none`,
                `${errors.content && "border-red-500"}`
              )}
              rows={4}
              {...register("content")}
            />
            <p
              className={twMerge(
                `text-red-500 text-xs italic mb-2 invisible`,
                `${errors.content && "visible"}`
              )}
            >
              {errors.content ? errors.content.message : ""}
            </p>
          </div>
          <FileUpLoader name="image" />
          <LoadingButton loading={isLoading} textColor="text-ct-blue-600">
            Create Post
          </LoadingButton>
        </form>
      </FormProvider>
    </section>
  );
};

export default CreatePost;

However, if the mutation ends in an error, an alert notification will be displayed to show the error sent by the tRPC server.

tRPC and React Query UPDATE Request

Finally, let’s create a component to perform the UPDATE operation of CRUD. This component will also contain a form that will have a title, category, content, and image fields. These input fields will be automatically filled with initial values when the component mounts.

When the form is submitted and is valid, React-Hook-Form will call the onSubmitHandler function. The onSubmitHandler function will then call the updatePost function which will trigger the updatePost mutation hook.

The updatePost mutation hook will add the form data to the request body and evoke the updatePost procedure on the tRPC server to update the fields of the record that matches the query. If the mutation results in a success, the .refetchQueries([["getPosts"]]) method will be evoked to re-fetch the new list of records from the tRPC API.

packages/client/src/components/posts/update.post.tsx


import React, { FC, useEffect } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import FileUpLoader from "../FileUpload";
import { LoadingButton } from "../LoadingButton";
import TextInput from "../TextInput";
import { toast } from "react-toastify";
import useStore from "../../store";
import { IPost } from "../../lib/types";
import { trpc } from "../../trpc";
import { useQueryClient } from "@tanstack/react-query";

type IUpdatePostProps = {
  post: IPost;
  setOpenPostModal: (openPostModal: boolean) => void;
};

const updatePostSchema = object({
  title: string().min(1, "Title is required"),
  category: string().min(1, "Category is required"),
  content: string().min(1, "Content is required"),
  image: string().min(1, "Image is required"),
});

type UpdatePostInput = TypeOf<typeof updatePostSchema>;

const UpdatePost: FC<IUpdatePostProps> = ({ post, setOpenPostModal }) => {
  const queryClient = useQueryClient();
  const store = useStore();
  const { isLoading, mutate: updatePost } = trpc.updatePost.useMutation({
    onSuccess(data) {
      store.setPageLoading(false);
      setOpenPostModal(false);
      queryClient.refetchQueries([["getPosts"]]);
      toast("Post updated successfully", {
        type: "success",
        position: "top-right",
      });
    },
    onError(error: any) {
      store.setPageLoading(false);
      setOpenPostModal(false);
      toast(error.message, {
          type: "error",
          position: "top-right",
        });
    },
  });
  const methods = useForm<UpdatePostInput>({
    resolver: zodResolver(updatePostSchema),
  });

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

  useEffect(() => {
    if (isLoading) {
      store.setPageLoading(true);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoading]);

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

  const onSubmitHandler: SubmitHandler<UpdatePostInput> = async (data) => {
    updatePost({ body: data, params: { postId: post._id } });
  };
  return (
    <section>
      <h2 className="text-2xl font-semibold mb-4">Update Post</h2>
      <FormProvider {...methods}>
        <form className="w-full" onSubmit={handleSubmit(onSubmitHandler)}>
          <TextInput name="title" label="Title" />
          <TextInput name="category" label="Category" />
          <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-ct-dark-200 rounded w-full py-3 px-3 text-gray-700 mb-2 leading-tight focus:outline-none`,
                `${errors.content && "border-red-500"}`
              )}
              rows={4}
              {...register("content")}
            />
            <p
              className={twMerge(
                `text-red-500 text-xs italic mb-2 invisible`,
                `${errors.content && "visible"}`
              )}
            >
              {errors.content ? errors.content.message : ""}
            </p>
          </div>
          <FileUpLoader name="image" />
          <LoadingButton loading={isLoading} textColor="text-ct-blue-600">
            Update Post
          </LoadingButton>
        </form>
      </FormProvider>
    </section>
  );
};

export default UpdatePost;

On the other hand, if the mutation results in an error, an alert message will be displayed to indicate the error sent by the tRPC API.

Conclusion

With this React Query, tRPC Client, tailwindCss, React-Hook-Form, and Zod example in TypeScript, you’ve learned how to perform CRUD (Create, Read, Update, and Delete) operations against a tRPC API.

tRPC Full-Stack App Source Code

You can find the complete source code on my GitHub.