TRPC Tutorial For Beginners- The End Of REST and GraphQL?
TRPC Tutorial For Beginners- The End Of REST and GraphQL?

Here is a TRPC tutorial for you. You will learn what is TRPC, how it differs from REST or GraphQL and on the real example you will see it's benefits.

So what is TRPC? Typically for client/server communication we are using REST. REST is the most popular way to build such communication.

GraphQL

After some time we got GraphQL which tried to fix REST problems by introducing strict rules in this layer of communication.

So in REST we don't any rules at all and this is wild west. Yes there are some guidelines but you can break them all and implement a route without any rules. Also you never know what data you must send.

With GraphQL it is much better. Your client always know what to send because we have a strict schema between client and server. And essentially GraphQL works extremely well.

Now we are getting TRPC. The idea is that on backend and on the client we have Typescript. It's a requirement. You can't use TRPC if you don't have it on both sides.

trpc example

But if you have Typescript and you use TRPC you are getting an amazing autocomplete and requests validation on your client. You will never have a case that you client doesn't know how your API was changed.

It is possible because you are using the same entities and the same routes on the client and on the server and Typescript validates them.

You will never have a case that API was changed and you don't see errors in your client code. This is extremely safe and you don't need to build an additional layer between client and server like we must do with GraphQL.

With both REST and GraphQL you won't notice API changes in client until it breaks in runtime.

Initial project

Here I already prepared a small project which is a list of artists.

initial project

We are getting the data from the real database through API. It is also possible to add and remove an artist from the list. The client side is build with React.

import axios from "axios";
import { FormEvent, useEffect, useState } from "react";
import { Artist } from "./types/Artist";

axios.defaults.baseURL = "http://localhost:3012";

const App = () => {
  const [artists, setArtists] = useState<Artist[]>([]);
  const [newArtistName, setNewArtistName] = useState<string>("");
  const addArtist = (e: FormEvent<HTMLFormElement>): void => {
    e.preventDefault();
    axios
      .post<Artist>("/artists", { name: newArtistName })
      .then((response) => {
        const updatedArtists = [...artists, response.data];
        setArtists(updatedArtists);
      });

    setNewArtistName("");
  };

  const deleteArtist = (artistId: string) => {
    axios.delete(`/artists/${artistId}`).then(() => {
      const updatedArtists = artists.filter(
        (artist) => artist._id !== artistId
      );
      setArtists(updatedArtists);
    });
  };

  useEffect(() => {
    axios.get<Artist[]>("/artists").then((response) => {
      setArtists(response.data);
    });
  }, []);

  return (
    <div>
      <h1>Artists</h1>
      <div>
        <form onSubmit={addArtist}>
          <input
            type="text"
            value={newArtistName}
            onChange={(e) => setNewArtistName(e.target.value)}
          />
        </form>
      </div>
      <div>
        {artists.map((artist) => (
          <div key={artist._id}>
            {artist.name}{" "}
            <span onClick={() => deleteArtist(artist._id)}>X</span>
          </div>
        ))}
      </div>
    </div>
  );
};

export default App;

Here we fetch a list of artists on initialze and render them on the screen. We also have 2 calls to add an artist and remove an artists.

Additionally we have our server which implements all these API calls.

app.get("/artists", artistsController.all);
app.post("/artists", artistsController.create);
app.delete("/artists/:id", artistsController.deleteById);

Inside each method we make a database call and respond with API like this

export const all = async (_: ExpressRequest, res: ExpressResponse, next: NextFunction ): Promise<void> => {
  try {
    const docs = await Artists.all();
    res.send(docs);
  } catch (err) {
    next(err);
  }
};

Most importantly both client and server is written with Typescript so we can integrate TRPC inside this project

Installing packages

First of all let's start with server side.

npm i @trpc/server zod

@trpc/server is a TRPC implementation for our server and zod allows us to validate parameters and body of our requests.

TRPC instance

The next thing that we want to configure is a trpc instance.

// server/trpc.ts
import { initTRPC } from "@trpc/server";
export const trpc = initTRPC.create();

It must be created only once and we will reuse it across the whole application.

Adding ZOD

Our next step is to update our types and interfaces on server because we want to cover them with Zod.

// server/types/artist.ts
export type Artist {
  _id: string
  name: string
}
export type ArtistWithoutId = Omit<Artist, '_id'>

This is what we have now. It's just two types. But it is not good as we want to cover everything with Zod.

// server/types/artist.ts
import z from "zod";

export const Artist = z.object({
  _id: z.string(),
  name: z.string(),
});

export const ArtistWithoutId = Artist.omit({ _id: true });

export type Artist = z.infer<typeof Artist>;
export type ArtistWithoutId = z.infer<typeof ArtistWithoutId>;

Here we used zod and as a result we got not just 2 types but 2 real entities and 2 types that we got from it.

The main benefit of the Zod that it check not only Typescript compiling but also runtime.

Here we used z.object to generate an entity and z.infer to generate a data type from it.

Connecting TRPC

Now let's bind our TRPC with Express on the backend.

// server/main.ts
import { createExpressMiddleware } from "@trpc/server/adapters/express";
...
app.use(
  "/trpc",
  createExpressMiddleware({
    router: appRouter,
  })
);
...

Here we basically register a single route for /trpc. All logic of TRPC we will write inside appRouter that we must create now.

// server/router/index.ts
import { trpc } from "../trpc";
import { artistsRouter } from "./artists";

export const appRouter = trpc.router({
  artists: artistsRouter,
});

Here we create an appRouter which is just an object. The idea of it the we can split our whole API in different namespaces. Like artistsRouter works with artists and postsRouter works with posts.

// server/router/artists.ts
import { trpc } from "../trpc";

export const artistsRouter = trpc.router({})

Here is our artistsRouter where we will pack all logic of any artists requests on the server.

Getting a list of artists

Our first goal now is to implement getting the list of users inside TRPC.

// server/router/artists.ts
export const artistsRouter = trpc.router({
  all: trpc.procedure.query(() => {
    return ArtistsModel.all();
  }),
});

We can name key as we want in our case it is all. When we make a get request it is called query so here we register trpc.procedure.query which returns a promise. In our cases we use ArtistsModel to work the database.

// server/models/artists.ts
export const all = (): Promise<Artist[]> => {
  return db.get().collection("artists").find<Artist>({}).toArray();
};

Here is how ArtistsModel.all is working. As we are using MongoDB this is how we are getting data from the database. But it is not related to TRPC at all. You can write anything inside trpc.procedure.

all

In order to make a request we use /trpc/artists.all and we are getting a list of artists from the database back.

This artists.all route is there exactly from our nested artistsRouter that we created.

In your case you will get zero records back as your database is empty. This is totally fine as our next step is to create an artist.

Creating an artist

Let's add a creation step.

// server/router/artists.ts
import { ArtistWithoutId } from "../types/artist";
export const artistsRouter = trpc.router({
  ...
  create: trpc.procedure.input(ArtistWithoutId).mutation(({ input }) => {
    return ArtistsModel.create(input);
  }),
});

Here we used not trpc.procedure.query but trpc.procedure.mutation because we are not getting data but creating something new instead. Additionally to that before mutation we user input. It allows us to specify and validate what data are needed for artist creation. This is why we can destructure our input and call a model to create an artist.

creating

So here we made a POST request on artists.create and in body we must provide a name property. As you can see our record was created.

Find by ID

Now it is time to find a record by ID.

// server/router/artists.ts
export const artistsRouter = trpc.router({
  ...
  findById: trpc.procedure.input(z.string()).query(({ input }) => {
    return ArtistsModel.findById(input);
  }),
});

Here we created findById property which need a string parameter (it's our ID of the user that we want to find). Inside we are calling a model like always.

But here is a huge problem. It's a get request so we must provide our parameter as a query parameter.

/trpc/artists.findById?input=65831bb4874cde21860c8450

Here we provide a MongoDB id of the record that we already created. But this code won't work. If we execute it we will get an error.

find by id error

It happens because any query parameters must be encoded first. It is needed because we can provide not only string but complex objects or arrays.

encodeURIComponent(JSON.stringify("65831bb4874cde21860c8450"))

This is how we can stringify and encode our ID. Now we can safely use it in the URL

/trpc/artists.findById?input=%2265831bb4874cde21860c8450%22

Nowe we are getting the correct user back that we found by ID.

Deleting the artist

The next thing that we need is to implement delete method.

The most important thing to remember that we use only GET and POST inside TRPC. So delete also uses POST

// server/router/artists.ts
export const artistsRouter = trpc.router({
  ...
  deleteById: trpc.procedure
    .input(z.object({ _id: z.string() }))
    .mutation(({ input }) => {
      return ArtistsModel.deleteById(input._id);
    }),
});

Here the code is similar we need to provide an ID that we want to remove inside the body.

Delete

Here is our POST request on /artists.deleteById with a body where inside we provided _id property.

Updating an artist

The last thing that we need on our backend is updating of the artist.

// server/router/artists.ts
export const artistsRouter = trpc.router({
  ...
  update: trpc.procedure.input(Artist).mutation(({ input }) => {
    return ArtistsModel.update(input._id, { name: input.name });
  }),
});

Some story here. We must provide _id and name in the body so we can update our artist.

update

Here is our POST request on artists.update with the properties in body to find the record and to update it.

We successfully implemented TRPC API on the server.

Setting up TRPC on the client

Now we are coming to the client part. We must just back to our React project and install needed dependencies.

npm i @trpc/server @trpc/client @trpc/react-query @tanstack/react-query

First 2 package are TRPC itself. Also TRPC uses react-query library under the hood to bring TRPC to react. This is we need to install second 2 libraries.

The next step is to setup TRPC instance on the client just like we did on the server. But in order to do that we must update our appRouter on the server.

// server/router/index.ts
export const appRouter = trpc.router({
  artists: artistsRouter,
});

export type AppRouter = typeof appRouter;

We added here AppRouter to our router because it stores all our routes from the backend that we want to reuse on client. This is exactly the main goal of TRPC.

// client/src/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../../server/router";

export const trpc = createTRPCReact<AppRouter>();

Here we created an instance of TRPC on the client and used AppRouter as a data type inside. This is why our TRPC instance on the client knows everything about all our server routes.

Our next step is to setup React Query and TRPC on the client.

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { createRoot } from "react-dom/client";
import App from "./App";
import { trpc } from "./trpc";

const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: "http://localhost:3012/trpc",
    }),
  ],
});

createRoot(document.getElementById("root")!).render(
  <trpc.Provider client={trpcClient} queryClient={queryClient}>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </trpc.Provider>
);

Here we created queryClient and trpcClient and we wrapped our App component in both of them. Now our whole app is ready to work with our backend TRPC. Also as you can see we provided in trpcClient a link to our API with TRPC.

Testing TRPC

Now let's use it in our application.

// src/app/App.tsx
import axios from "axios";
import { FormEvent, useState } from "react";
import { trpc } from "./trpc";

axios.defaults.baseURL = "http://localhost:3012";

const App = () => {
  const artistsQuery = trpc.artists.all.useQuery();
  const artistsCreateMutation = trpc.artists.create.useMutation();
  const artistsDeleteMutation = trpc.artists.deleteById.useMutation();
  const trpcContext = trpc.useContext();
  const [newArtistName, setNewArtistName] = useState<string>("");
  const addArtist = (e: FormEvent<HTMLFormElement>): void => {
    e.preventDefault();
    artistsCreateMutation.mutate(
      { name: newArtistName },
      {
        onSuccess: () => {
          trpcContext.artists.all.invalidate();
        },
      }
    );

    setNewArtistName("");
  };

  const deleteArtist = (artistId: string) => {
    artistsDeleteMutation.mutate(
      { _id: artistId },
      {
        onSuccess: () => {
          trpcContext.artists.all.invalidate();
        },
      }
    );
  };

  return (
    <div>
      <h1>Artists</h1>
      <div>
        <form onSubmit={addArtist}>
          <input
            type="text"
            value={newArtistName}
            onChange={(e) => setNewArtistName(e.target.value)}
          />
        </form>
      </div>
      <div>
        {artistsQuery.data?.map((artist) => (
          <div key={artist._id}>
            {artist.name}{" "}
            <span onClick={() => deleteArtist(artist._id)}>X</span>
          </div>
        ))}
      </div>
    </div>
  );
};

export default App;

Here is our refactored code to use TRPC. Let's break it down.

const artistsQuery = trpc.artists.all.useQuery();

This line creates a React Query which will synchronize our data from API with local list of the artists.

Autocomplete

As you can see in screen we got an amazing autocomplete for all our methods from the server router. We can't write something wrong at all because it is covered with Typescript. And every change on server will be seen and validated by Typescript on the client.

const artistsDeleteMutation = trpc.artists.deleteById.useMutation();

This line creates a mutation to delete an artist which we can call anywhere to trigger a deletion.

const trpcContext = trpc.useContext();
...
const deleteArtist = (artistId: string) => {
  artistsDeleteMutation.mutate(
    { _id: artistId },
    {
      onSuccess: () => {
        trpcContext.artists.all.invalidate();
      },
    }
  );
};

In order to refetch data again when we add or remove an artist we must trigger invalidate in success. In order to do that we must inject trpcContext in our component.

initial project

As you can see the whole project works just like before.

And actually if you want to improve your Typescript knowledge and prepare for the interview I highly recommend you to check my course Typescript Interview Questions.

📚 Source code of what we've done