DEV Community

Cover image for Build a full-stack TypeScript app using tRPC and React
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Build a full-stack TypeScript app using tRPC and React

Written by Mario Zupan✏️

You may already be familiar with the remote procedure call framework gRPC. Given the similarity in naming, you might be inclined to believe that tRPC is somehow related to it, or does the same or a similar thing. However, this is not the case.

While tRPC is indeed also a remote procedure call framework, its goals and basis differ fundamentally from gRPC. The main goal of tRPC is to provide a simple, type-safe way to build APIs for TypeScript and JavaScript-based projects with a minimal footprint.

In this article, we’ll build a simple, full-stack TypeScript app using tRPC that will be type-safe when it comes to the code and across the API boundary. We’ll build a small, cat-themed application to showcase how to set up tRPC on the backend and how to consume the created API within a React frontend. You can find the full code for this example on GitHub. Let’s get started!

Exploring tRPC

If you have an application that uses TypeScript on both the backend and the frontend, tRPC helps you set up your API in a way that incurs the absolute minimum overhead in terms of dependencies and runtime complexity. However, tRPC still provides type safety and all the features that come with it, like auto-completion for the whole API and errors for when the API is used in an invalid way.

In practical terms, you can think of tRPC it as a very lightweight alternative to GraphQL. However, tRPC is not without its limitations. For one, it’s limited to TypeScript and JavaScript. Additionally, the API you are building will follow the tRPC model, which means it won’t be a REST API. You can’t simply convert a REST API to tRPC and have the same API as before but with types included.

Essentially, tRPC is a batteries-included solution for all your API needs, but it will also be a tRPC-API. That’s where the RPC in the name comes from, fundamentally changing how remote calls work. tRPC could be a great solution for you as long as you’re comfortable using TypeScript on your API gateway.

Setting up tRPC

We’ll start by creating a folder in our project root called server. Within the server folder, we create a package.json file as follows:

{
  "name": "server",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@tsconfig/node14": "^1.0.1",
    "typescript": "^4.5"
  },
  "dependencies": {
    "@trpc/server": "^9.21.0",
    "@types/cors": "^2.8.12",
    "@types/express": "^4.17.13",
    "cors": "^2.8.5",
    "express": "^4.17.2",
    "zod": "^3.14.2"
  }
}
Enter fullscreen mode Exit fullscreen mode

We’ll also create a tsconfig.json file:

{
  "extends": "@tsconfig/node14/tsconfig.json",
  "compilerOptions": {
      "outDir": "build"
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, create an executable run.sh file:

#!/bin/bash -e

./node_modules/.bin/tsc && node build/index.js
Enter fullscreen mode Exit fullscreen mode

Next, we create a folder called src and include an index.ts file within it. Finally, we execute npm install in the server folder. With that, we’re done with the setup for the backend.

For the frontend, we’ll use Create React App to set up a React app with TypeScript support using the following command within the project root:

npx create-react-app client --template typescript
Enter fullscreen mode Exit fullscreen mode

We can also run npm install in the client folder and run the app with npm start to see that everything works and is set up properly. Next, we’ll implement the backend of our application.

Setting up our Express backend

Install dependencies

As you can see above in the package.json of the server part of our application, we use Express as our HTTP server. Additionally, we add TypeScript and the trpc-server dependency.

Beyond that, we used the cors library for adding CORS to our API, which isn’t really necessary for this example, but it is good practice. We also add Zod, a schema validation library with TypeScript support, which is often used in combination with tRPC. However, you can also use other libraries like Yup or Superstruct. We’ll see exactly what this is for later on.

With the dependencies out of the way, let’s set up our basic Express backend with tRPC support.

Backend with Express

We’ll start by defining the tRPC router, which is a very important part of this whole infrastructure, allowing us to wire together our backend and frontend in terms of type safety and autocompletion. This router should be in its own file, for example router.ts, since we’ll import it into our React app later on as well.

Within router.ts, we start by defining the data structure for our domain object, Cat:

let cats: Cat[] = [];

const Cat = z.object({
    id: z.number(),
    name: z.string(),
});
const Cats = z.array(Cat);

...

export type Cat = z.infer<typeof Cat>;
export type Cats = z.infer<typeof Cats>;
Enter fullscreen mode Exit fullscreen mode

You might be wondering why we’re not building simple JavaScript or TypeScript types and objects. Since we use Zod for schema validation with tRPC, we also need to build these domain objects with it. We can actually add validation rules using Zod, like a maximum amount of characters for a string, email validation, and more, combining type checking with actual validation.

We also get automatically created error messages when an input is not valid. However, these errors can be entirely customized. If you’re interested in validation and error handling, check out the docs for more information.

After implementing our type using Zod, we can infer a TypeScript type from it using z.infer. Once we have that, we export the type to use in other parts of the app like the frontend, and then move on to creating the heart of the application, the router:

const trpcRouter = trpc.router()
    .query('get', {
        input: z.number(),
        output: Cat,
        async resolve(req) {
            const foundCat = cats.find((cat => cat.id === req.input));
            if (!foundCat) {
                throw new trpc.TRPCError({
                    code: 'BAD_REQUEST',
                    message: `could not find cat with id ${req.input}`,
                });
            }
            return foundCat;
        },
    })
    .query('list', {
        output: Cats,
        async resolve() {
            return cats;
        },
    })
Enter fullscreen mode Exit fullscreen mode

We can create a tRPC router by calling the router() method and chaining our different endpoints onto it. We can also create multiple routers and combine them. In tRPC, there are two types of procedures:

  • Query: Used for fetching data. Think GET
  • Mutation: Used for changing data. Think POST, PUT, PATCH, DELETE

In the code snippet above, we create our query endpoints, one for fetching a singular Cat object by ID and one for fetching all Cat objects. tRPC also supports the concept of infiniteQuery, which takes a cursor and can return a paged response of potentially infinite data, if needed.

For out GET endpoint, we define an input. This endpoint will essentially be a GET /get?input=123 endpoint, returning the JSON of our Cat based on the definition above.

We can define multiple inputs if we need them. In the resolve async function, we implement our actual business logic. In a real world application, we might call a service or a database layer. However, since we’re just saving our Cat objects in-memory, or in an array, we check if we have a Cat with the given ID and if not, we throw an error. If we find a Cat, we return it.

The list endpoint is even simpler since it takes no input and returns only our current list of Cat objects. Let’s look at how we can implement creation and deletion with tRPC:

    .mutation('create', {
        input: z.object({ name: z.string().max(50) }),
        async resolve(req) {
            const newCat: Cat = { id: newId(), name: req.input.name };
            cats.push(newCat)
            return newCat
        }
    })
    .mutation('delete', {
        input: z.object({ id: z.number() }),
        output: z.string(),
        async resolve(req) {
            cats = cats.filter(cat => cat.id !== req.input.id);
            return "success"
        }
    });

function newId(): number {
    return Math.floor(Math.random() * 10000)
}

export type TRPCRouter = typeof trpcRouter;
export default trpcRouter;
Enter fullscreen mode Exit fullscreen mode

As you can see, we use the .mutation method to create a new mutation. Within it, we can, again, define input, which in this case will be a JSON object. Be sure to note the validation option we provided here for the name.

In resolve, we create a new Cat from the given name with a random ID. Check the newId function at the bottom and add it to our list of Cat objects, returning the new Cat to the caller. Doing so will result in something like a POST /create expecting some kind of body. If we use the application/json content-type, it will return JSON to us and expect JSON.

In the delete mutation, we expect a Cat object's ID, filter the list of Cat objects for that ID, and update the list, returning a success message to the user. The responses don’t actually look like what we define here. Rather, they are wrapped inside of a tRPC response like the one below:

{"id":null,"result":{"type":"data","data":"success"}}
Enter fullscreen mode Exit fullscreen mode

And that’s it for our router; we have all the endpoints we need. Now, we’ll have to wire it up with an Express web app:

import express, { Application } from 'express';
import cors from 'cors';
import * as trpcExpress from '@trpc/server/adapters/express';
import trpcRouter from './router';

const app: Application = express();

const createContext = ({}: trpcExpress.CreateExpressContextOptions) => ({})

app.use(express.json());
app.use(cors());
app.use(
    '/cat',
    trpcExpress.createExpressMiddleware({
        router: trpcRouter,
        createContext,
    }),
);

app.listen(8080, () => {
    console.log("Server running on port 8080");
});
Enter fullscreen mode Exit fullscreen mode

tRPC comes with an adapter for Express, so we simply create our Express application and use the provided tRPC middleware inside of the app. We can define a sub-route where this configuration should be used, a router, and a context.

The context function is called for each incoming request, and it passes its result to the handlers. In the context function, you could add the context data you want for each request, like an authentication token or the userId of a user who is logged in.

If you want to learn more about authorization with tRPC, there’s a section about it in the docs.

Testing our Express backend

That’s it for the app! Let’s test it quickly so we know everything is working correctly We can start the app by executing the ./run.sh file and send off some HTTP requests using cURL. First, let’s create a new Cat:

curl -X POST "http://localhost:8080/cat/create" -d '{"name": "Minka" }' -H 'content-type: application/json'

{"id":null,"result":{"type":"data","data":{"id":7216,"name":"Minka"}}}
Enter fullscreen mode Exit fullscreen mode

Then, we can list the existing Cat objects:

curl "http://localhost:8080/cat/list"

{"id":null,"result":{"type":"data","data":[{"id":7216,"name":"Minka"}]}}
Enter fullscreen mode Exit fullscreen mode

We can also fetch the Cat by its ID:

curl "http://localhost:8080/cat/get?input=7216"

{"id":null,"result":{"type":"data","data":{"id":7216,"name":"Minka"}}}
Enter fullscreen mode Exit fullscreen mode

And finally, delete a Cat:

curl -X POST  "http://localhost:8080/cat/delete" -d '{"id": 7216}' -H 'content-type: application/json'

{"id":null,"result":{"type":"data","data":"success"}}

curl "http://localhost:8080/cat/list"

{"id":null,"result":{"type":"data","data":[]}}
Enter fullscreen mode Exit fullscreen mode

Everything seems to work as expected. Now, with the backend in place, let’s build our React frontend.

Creating our React frontend

First, within the src folder, let’s create a cats folder to add some structure in our application. Then, we add some additional dependencies:

npm install --save @trpc/client @trpc/server @trpc/react react-query zod
Enter fullscreen mode Exit fullscreen mode

We need the server for type safety, the client for the minimal logic needed to make calls to an API, zod, as mentioned before, for schema validation, trpc/react for easier integration with React Query, and finally React Query. However, it’s also possible to use trpc/client on its own, or completely vanilla, which is also covered in the docs.

In this example, like in the official ones, we’ll use React Query, which adds API-interaction to React apps. Adding React Query is completely optional, and it’s possible to just use a vanilla client with the frontend framework of your choice, including React, and integrate it exactly the way you want to. Let’s start by building the basic structure of our app in App.tsx:

import { useState } from 'react';
import './App.css';
import type { TRPCRouter } from '../../server/src/router';
import { createReactQueryHooks } from '@trpc/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import Create from './cats/Create';
import Detail from './cats/Detail';
import List from './cats/List';

const BACKEND_URL: string = "http://localhost:8080/cat";

export const trpc = createReactQueryHooks<TRPCRouter>();

function App() {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() => trpc.createClient({ url: BACKEND_URL }));

  const [detailId, setDetailId] = useState(-1);

  const setDetail = (id: number) => {
    setDetailId(id);
  }

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <div className="App">
          <Create />
          <List setDetail={setDetail}/>
          { detailId > 0 ? <Detail id={detailId} /> : null }
        </div>
      </QueryClientProvider>
    </trpc.Provider>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

There’s quite a bit to unpack, so let’s start from the top. We instantiate trpc using the createReactQueryHooks helper from trpc/react, giving it the TRPCRouter that we import from our backend app. We also export for use in the rest of our app.

Essentially, this creates all the bindings towards our API underneath. Next, we create a React Query client and a tRPC-client to provide the URL for our backend. We’ll use this client to make requests to the API, or rather the client React Query will use underneath.

In addition to all of this setup, we also define a state variable for detailId so we know which Cat detail to show if the user selects any.

If you check out what we return from App, you can see that our actual markup, the div with the App class, is nested within two layers. These layers are on the outer side, the tRPC provider, and inside that, the React Query provider.

These two components make the necessary moving parts available to our whole application. Therefore, we can use tRPC throughout our application, and our query calls get seamlessly integrated with our React app. Next, we'll add components for Create, List, and Detail to our markup, which will include all of our business logic.

Let’s start with the Create component by creating a Create.css and Create.tsx file inside the src/cats folder. In this component, we’ll simply create a form and connect the form to the create mutation we implemented on the backend. Once a new Cat has been created, we want to re-fetch the list of Cat objects so that it’s always up to date. We could implement this with the following code:

import './Create.css';
import { ChangeEvent, useState } from 'react';
import { trpc } from '../App';

function Create() {
  const [text, setText] = useState("");
  const [error, setError] = useState("");

  const cats = trpc.useQuery(['list']);
  const createMutation = trpc.useMutation(['create'], {
    onSuccess: () => {
      cats.refetch();
    },
    onError: (data) => {
      setError(data.message);
    }
  });

  const updateText = (event: ChangeEvent<HTMLInputElement>) => {
    setText(event.target.value);
  };

  const handleCreate = async() => {
    createMutation.mutate({ name: text });
    setText("");
  };

  return (
    <div className="Create">
      {error && error}
      <h2>Create Cat</h2>
      <div>Name: <input type="text" onChange={updateText} value={text} /></div>
      <div><button onClick={handleCreate}>Create</button></div>
    </div>
  );
}

export default Create;
Enter fullscreen mode Exit fullscreen mode

Let's start off with some very basic, vanilla React logic. We create some internal component state for our form field and the potential errors we might want to show. We return a simple form featuring a text field connected to our state, as well as a button to submit it.

Now, let’s look at the handleCreate function. We call .mutate on the createMutation, which we define above it and reset the text field afterwards.

The createMutation is created using trpc.useMutation with our create endpoint. In your IDE or editor, note that when typing create within the useMutation call, you'll get autocomplete suggestions. We also get suggestions in the payload for the .mutate call suggesting that we use the name field.

Inside the .useMutation call, we define what should happen on success and on error. If we encounter an error, we simply want to display it using our component-internal state. If we successfully create a Cat, we want to re-fetch the data for our list of Cat objects. For this purpose, we define a call to this endpoint using trpc.useQuery with our list endpoint and call it inside the onSuccess handler.

We can already see how easy it is to integrate our app with the tRPC API, as well as how tRPC helps us during development. Let’s look at the detail view next, creating Detail.tsx and Detail.css within the cats folder:

import './Detail.css';
import { trpc } from '../App';

function Detail(props: {
  id: number,
}) {
  const cat = trpc.useQuery(['get', props.id]);

  return (
    cat.data ? 
      <div className="Detail">
        <h2>Detail</h2>
        <div>{cat.data.id}</div>
        <div>{cat.data.name}</div>
      </div> : <div className="Detail"></div>
  );
}

export default Detail;
Enter fullscreen mode Exit fullscreen mode

In the component above, we basically just use .useQuery again to define our getCatById endpoint, providing the ID we get from our root component via props. If we actually get data, we render the details of the Cat. We could also use effects for the data fetching here. Essentially, any way you would integrate an API with your React app will work fine with tRPC and React Query.

Finally, let’s implement our List component by creating List.css and List.tsx in cats. In our list of Cat objects, we’ll display the ID and name of a Cat, as well as a link to display it in detail and a link to delete it:

import './List.css';
import { trpc } from '../App';
import type { Cat } from '../../../server/src/router';
import { useState } from 'react';

function List(props: {
  setDetail: (id: number) => void,
}) {
  const [error, setError] = useState("");
  const cats = trpc.useQuery(['list']);
  const deleteMutation = trpc.useMutation(['delete'], {
    onSuccess: () => {
      cats.refetch();
    },
    onError: (data) => {
      setError(data.message);
    }
  });

  const handleDelete = async(id: number) => {
    deleteMutation.mutate({ id })
  };

  const catRow = (cat: Cat) => {
    return (
      <div key={cat.id}>
        <span>{cat.id}</span>
        <span>{cat.name}</span>
        <span><a href="#" onClick={props.setDetail.bind(null, cat.id)}>detail</a></span>
        <span><a href="#" onClick={handleDelete.bind(null, cat.id)}>delete</a></span>
      </div>
    );
  };

  return (
    <div className="List">
      <h2>Cats</h2>
      <span>{error}</span>
      { cats.data && cats.data.map((cat) => {
        return catRow(cat);
      })}
    </div>
  );
}

export default List;
Enter fullscreen mode Exit fullscreen mode

This component basically combines the functionality we used in the two previous ones. For one, we fetch the list of cats using useQuery on our list endpoint and also implement the deletion of Cat objects with a subsequent re-fetch using deleteMutation, pointing to our delete mutation on the backend.

Besides that, everything is quite similar. We pass in the setDetailId function from App via props so that we can set the cat to show details in Detail and create a handler for deleting a cat, which executes our mutation.

Notice all the autocompletion provided by tRPC. If you mistype something, for example, the name of an endpoint, you will get an error, and the frontend won't start until the error is corrected. That’s it for our frontend, let’s test it and see tRPC in action!

Testing and tRPC features

First, let’s start the app with npm start and see how it works. Once the app is up, we can create new cats, delete them, and watch their detail page while observing the changes directly in the list. It’s not particularly pretty, but it works!

Create New Cat Display

Create List Detail Cat

Let’s take a look at how tRPC can help us during our development process. Let’s say we want to add an age field for our cats:

const Cat = z.object({
    id: z.number(),
    name: z.string(),
    age: z.number(),
});

...
    .mutation('create', {
        input: z.object({ name: z.string().max(50), age: z.number().min(1).max(30) }),
        async resolve(req) {
            const newCat: Cat = { id: newId(), name: req.input.name, age: req.input.age };
            cats.push(newCat)
            return newCat
        }
    })
...
Enter fullscreen mode Exit fullscreen mode

We add the field to our domain object, and we also need to add it to our create endpoint. Once you hit save on your backend code, navigate back to your frontend code in ./client/src/cats/Create.tsx. Our editor shows us an error. The property age is missing in our call to createMutation:

Editor Shows Error Missing Age

If we want to add the age field to our mutation now, our editor will provide us with autocomplete with full type-information directly from our changed router.ts:

Add Age Field Autocomplete

From my perspective, this is the true power of tRPC. While it’s nice to have a simple way to create an API both on the frontend and the backend, the real selling point is the fact that the code actually won’t build if I make a breaking change on one side and not the other.

For example, imagine a huge codebase with multiple teams working on API endpoints and UI elements. Having this kind of safety in terms of API compatibility with almost no overhead to the application is quite remarkable.

Conclusion

Hopefully, this article showed you how tRPC could be quite useful in situations when one uses TypeScript both on the frontend and backend. I love the low-footprint approach. With minimal or no extra dependencies, you can focus on compile-time correctness instead of runtime-checking.

Obviously, in certain cases, the TypeScript limitation might be too much to bear. The principle behind tRPC is great in terms of developer experience. tRPC is an exciting project that I will certainly keep my eye on in the future. I hope you enjoyed this article! Happy coding.


Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

Write More Readable Code with TypeScript 4.4

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

Top comments (0)