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.
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.
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.
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
.
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.
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.
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.
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.
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.
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.
As you can see the whole project works just like before.
Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!
📚 Source code of what we've done