DEV Community

Andrej Tlčina
Andrej Tlčina

Posted on

Create fullstack book app: CRUD operations w/ tRPC

Hello! In this part of the series, I'd like to talk a little bit about CRUD operations done via tRPC in my made-up book app. That means our book app should be able to Create, Update, Read and Delete records. Also, we'll fetch (read) data from external API. At first, all the operations may feel a bit overwhelming. That's why I prefer to list all of the operations and start crossing them out of the list, one by one.

Here's my list

  • create a chapter (C)
  • display book detail with all the chapters (R)
  • update a chapter (U)
  • delete a chapter (D)

I think it makes sense to start with the read portion.

Display book detail with all the chapters

To get a book detail, first, we need to have its ID, in this case, we'll be using the isbn13 code. I've previously mentioned an API I've chosen to work with called https://api.itbook.store/. The API can fetch the newest books by calling https://api.itbook.store/1.0/new. You can look at what's being returned. I'll make a type of just some attributes. On top of that, I could've mapped over the result, but this isn't a production site and so I didn't bother. I got my fetch function from Kent C. Dodds' blog.

export type Book = {
  title: string;
  subtitle: string | null;
  isbn13: string;
  price: string;
  image: string;
};

export type BookList = {
  error: number;
  total: number;
  page?: number;
  books: Book[];
};

export function fetchBooks(type = "", customConfig = {}): Promise<BookList> {
  const config = {
    method: "GET",
    ...customConfig,
  };

  const url = `${process.env.BOOKS_URL}${type}`;

  return fetch(url, config).then(async (response) => {
    if (response.ok) {
      return await response.json();
    } else {
      const errorMessage = await response.text();
      return Promise.reject(new Error(errorMessage));
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

What I've done at first was to create a new router and chained a query function that calls fetchBooks function above, like this

export const booksRouter = createRouter()
  .query("newest", {
    async resolve() {
      const bookList = await fetchBooks("new");

      return bookList.books;
    },
  })
Enter fullscreen mode Exit fullscreen mode

Not sure how I feel about it yet, but I don't think this is the best, so either call it with react query

const result = useQuery('newest', async () => await fetchBooks('new')
Enter fullscreen mode Exit fullscreen mode

or in getServerSideProps of page component and pass it as props

type Props = {
  books: Books;
};

export const getServerSideProps: GetServerSideProps<Props> = async (
  ctx
) => {
  const book = await fetchBook('new');

  return {
    props: {
      books,
    },
  };
};

const NewestBooks = ({
  books,
}: InferGetServerSidePropsType<typeof getServerSideProps>) => {
  ...
}
Enter fullscreen mode Exit fullscreen mode

Now that we have a list of the newest books we can loop over them. Let's display some cards or something, important thing is we have to have a link to an individual book detail page. In general, you'll have something like this

books.maps((book) => {
  return (
    <div className="card">
       ...
       <Link href={`/books/${book.isbn13}`}>
         <a>...</a>
       </Link>
    </div>
  )
})
Enter fullscreen mode Exit fullscreen mode

Style it however you want. Don't forget to create new page at pages/books/[isbn13]/index.tsx. This might look weird, but later on, when working with chapters, I want to have URLs like

/books/[isbn13]/chapter/add <- for adding new chapter
and
/books/[isbn13]/chapter/[id] <- for updating existing chapter
Enter fullscreen mode Exit fullscreen mode

It probably doesn't make much of a difference, so don't sweat it if you'll have different file structure/routes, as long as it makes sense to you 😄. We're already at creating pages, so let's create pages for adding and updating chapters, like I mentioned previously.

At this point, we have the "newest books" page, "book detail" page, "add chapter" page and "edit chapter" page. In "book detail" page, we'll fetch the book by ISBN code by calling

import { useRouter } from 'next/router'

const BookDetail = () => {
  const router = useRouter()
  const { isbn13 } = router.query
}
Enter fullscreen mode Exit fullscreen mode

or in getServerSideProps

export const getServerSideProps: GetServerSideProps<LocalProps> = async (
  ctx
) => {
  const code = ctx?.params?.isbn13;
  ...
};
Enter fullscreen mode Exit fullscreen mode

We can fetch a book by isbn13 code by calling

export function fetchBook(isbn13 = "", customConfig = {}): Promise<Book> {
  const config = {
    method: "GET",
    ...customConfig,
  };

  const url = `${process.env.BOOKS_URL}books/${isbn13}`;

  return fetch(url, config).then(async (response) => {
    if (response.ok) {
      return await response.json();
    } else {
      const errorMessage = await response.text();
      return Promise.reject(new Error(errorMessage));
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Similarly, like we did fetch the newest books.

export const getServerSideProps: GetServerSideProps<LocalProps> = async (
  ctx
) => {
  const book = ctx.params
    ? await fetchBook(ctx?.params?.isbn13 as string)
    : null;

  return {
    props: {
      book,
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

sidenote: if you used useRouter, then fetch with react-query

Thanks to this request, the book detail may contain additional book info, but I definitely want list of chapters, that I can edit and delete, and I want to be able to create a new chapter.

Create a chapter

sidenote: when talking about the next chapter we're gonna be creating, lets use term, "callable" pieces of code. When talking about a query, picture using it at the top of some component, on the other hand, when talking about a mutation, picture some kind of a button user will be clicking

Creating a chapter makes sense to do the next, because I want to have some chapters in DB first, which I'll edit or delete later. The scenario is: user comes to the book detail page, where they can either add the book to their "library", or if they've already done so, can add a new chapter.
Let's define, that both of these actions are done by clicking some kind of a button. At this stage, I'm not thinking about which one I'm gonna show or not. I'll be doing testing and I know in which order I have to be clicking buttons.
Creating mutations for both buttons is a safe bet. I'll show you adding the book first

  .mutation("add-book", {
    input: z.object({
      title: z.string(),
      subtitle: z.string().nullish(),
      isbn13: z.string(),
      price: z.string(),
      image: z.string(),
    }),
    async resolve({ ctx, input }) {
      const bookNote = await ctx.prisma.bookNote.create({
        data: {
          ...input,
          authorId: ctx.user?.id || "",
        },
      });

      return bookNote;
    },
  })
Enter fullscreen mode Exit fullscreen mode

Do you remember context from the previous post? Having a user at each function is super handy, like here. So, we'll create a new book note. Once that's done, we can add a chapter by calling

  .mutation("add-chapter", {
    input: z.object({
      bookID: z.string(),
      payload: z.object({
        title: z.string(),
        text: z.string(),
      }),
    }),
    async resolve({ ctx, input }) {
      const chapter = await ctx.prisma.chapter.create({
        data: {
          ...input.payload,
          bookNoteId: input.bookID,
        },
      });

      return chapter;
    },
  })
Enter fullscreen mode Exit fullscreen mode

In both cases, we can generalize, that when creating a new record, we call create method of the particular model. There, we're passing a data object, that will expect certain values defined in the schema.

You hook to this mutation like this

import { trpc } from "utils/trpc"

const exampleMutation = trpc.useMutation(["router.example"]);

...

<button disabled={exampleMutation.isLoading} onClick={exampleMutation.mutate} >Click to mutate</button>
Enter fullscreen mode Exit fullscreen mode

Usually, after mutating, one wants to let's say refetch some kind of a query or redirect the user. Just as with react-query, you can use onSuccess attribute


const exampleMutation = trpc.useMutation(["router.example"], {
  onSuccess: (data) => {...}
});

Enter fullscreen mode Exit fullscreen mode

At this point, we have two buttons, but in production, I'd like to switch between them. I'll have to be checking if I already have the book in my "library", and that way I'll decide which button to show

  .query("get-book", {
    input: z.object({
      isbn13: z.string(),
    }),
    async resolve({ ctx, input }) {
      const bookNote = await ctx.prisma.bookNote.findFirst({
        where: {
          authorId: String(ctx.user?.id),
          isbn13: input.isbn13,
        },
      });

      return bookNote;
    },
  })
Enter fullscreen mode Exit fullscreen mode

Here, we're asking Prisma to give us a record with the given isbn13 and author. We call this query like this

import { trpc } from 'utils/trpc'

const exampleQuery = trpc.useQuery(["router.example", $input$]);
Enter fullscreen mode Exit fullscreen mode

You could have a different flow of adding chapters. Like

  .mutation("add-chapter", {
    input: z.object({
      book: z.object({
        title: z.string(),
        subtitle: z.string().nullish(),
        isbn13: z.string(),
        price: z.string(),
        image: z.string(),
      }),
      payload: z.object({
        title: z.string(),
        text: z.string(),
      }),
    }),
    async resolve({ ctx, input }) {
      const bookNote = await ctx.prisma.bookNote.upsert({
        where: {
          id: input.book.id 
        },
        update: {},
        data: {
          ...input.book,
          authorId: ctx.user?.id || "",
        },
      });

      const chapter = await ctx.prisma.chapter.create({
        data: {
          ...input.payload,
          bookNoteId: input.book.id,
        },
      });

      return chapter;
    },
  })
Enter fullscreen mode Exit fullscreen mode

This option uses upsert, which can be used as find or create. We're saying to find a book or create it otherwise, which ensures that, when we're adding a chapter, there will be a record with id of input.book.id. Your choice, I'll go with the first one 😀.

Update a chapter

Definition of function for updating a chapter is pretty straightforward, when adding a chapter, we were creating a record, which is C out of CRUD. When updating, you guessed it, we'll use the update

  .mutation("update-chapter", {
    input: z.object({
      chapterID: z.string(),
      payload: z.object({
        title: z.string(),
        text: z.string(),
      }),
    }),
    async resolve({ ctx, input }) {
      const chapter = await ctx.prisma.chapter.update({
        data: {
          ...input.payload,
        },
        where: {
          id: input.chapterID,
        },
      });

      return chapter;
    },
  })
Enter fullscreen mode Exit fullscreen mode

Delete a chapter

And at the end, we're deleting (literally calling delete).

  .mutation("remove-chapter", {
    input: z.object({
      chapterID: z.string(),
    }),
    async resolve({ ctx, input }) {
      await ctx.prisma.chapter.delete({
        where: {
          id: input.chapterID,
        },
      });

      return true;
    },
  })
Enter fullscreen mode Exit fullscreen mode

All of the mutations and query can be called the way you want, or the way it suits you best.

Conclusion

I know, that was intense! But, you've made it! And know you know how to define CRUD functions with tRPC. I hope you can see just how simple it can be.

Thanks for reading, and keep an eye out for the next part. Till then take care 😉

Top comments (0)