Building an Express, tRPC and React monorepo setup with yarn workspaces, tailwind, zod and react-query

Building an Express, tRPC and React monorepo setup with yarn workspaces, tailwind, zod and react-query

For seamless end-to-end communication between type-safe systems

Introduction

In this article, we will see what tRPC is, what benefits it provides and how we can set up a monorepo project with an Express backend serving tRPC requests and a React client using tRPC to query and mutate data. In the demo, we will be building a simple chat mechanism that fetches some data from the backend (query) and updates the data from the frontend (mutate).

React-Query:

React-Query is a library for React.js that offers data fetching that is more straightforward and state management for remote data. It offers hooks and components for dealing with the data retrieval, caching, and updating in response to user interactions and other application events. React-Query offers capabilities like automatic data fetching and caching, request deduplication, and simple error handling, and works seamlessly with the React.js environment. It is made to make it simple for developers to create scalable, high-performance web apps with less boilerplate code.

Zod:

Zod is a JavaScript type manipulation and validation library. It offers a straightforward and expressive method for defining, verifying, and manipulating data in JavaScript. Zod is type-safe, which means that it guards against runtime errors by checking data at runtime and warning the developer if there are any mistakes. With Zod, developers can create sophisticated validation schemas that may be used for a variety of purposes, from straightforward data validation to intricate business logic. Zod is a preferred option for many developers because it is made to be quick, light, and simple to use.

What is tRPC?

Characteristics

tRPC is a communication protocol that enables functions or processes to be carried out on a distant system as if they were being carried out locally. It is created using TypeScript, a superset of JavaScript that is statically typed.

Some of its most important traits include:

  • TypeScript support: tRPC is specifically designed to work with TypeScript, making it easy to write and maintain code.

  • Performance: tRPC allows for fast communication between systems, making it suitable for real-time applications.

  • Interoperability: tRPC is specifically designed for TypeScript and provides seamless end-to-end type-safe communication between frontend and backend systems.

How it works

A client and a server establish remote procedure calls to carry out tRPC operations. The client requests that the server carry out a particular procedure, and the server carries out the request and sends back the results to the client. The functionality of the server can thus be accessed and used by the client as if it were a local resource, enabling real-time communication and data exchange between the two systems.

Use cases

tRPC is commonly used in a variety of applications, including:

  • Real-time applications, such as gaming and multimedia streaming, where fast and secure communication is essential.

  • Distributed systems, where multiple systems need to communicate with each other and exchange information.

  • Microservices architecture, where different services need to interact with each other and exchange data.

  • Remote access to databases, where clients need to access data stored on a remote server.

A quick comparison with GraphQL, Rest and gRPC

  • Focus: tRPC focuses on executing procedures on a remote server in a type-safe manner, while GraphQL focuses on querying data from a server, REST focuses on accessing and manipulating resources on a server, and gRPC focuses on executing procedures on a remote server using Protocol Buffers.

  • Data encoding: TRPC typically uses JSON or XML to encode its data, while GraphQL uses a custom query language, REST uses standard HTTP methods, and gRPC uses Protocol Buffers.

  • Communication: TRPC allows for real-time communication between systems, while GraphQL, REST, and gRPC also support real-time communication, but with different capabilities and limitations.

Project Setup

Setting up the monorepo with backend and frontend packages

  • Create a new project folder and name it whatever you like, I am naming mine trpc-react.

  • Open this folder in your favourite code editor, I am using vs-code.

  • In the project folder create a new file package.json and past the following content inside it:

{
  "name": "packages",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "private": true,
  "scripts": {
    "start": "concurrently \"wsrun --parallel start\""
  },
  "workspaces": [
    "packages/*"
  ],
  "devDependencies": {
    "concurrently": "^5.2.0",
    "wsrun": "^5.2.0"
  }
}
  • Create a new folder inside the root folder, next to the package.json, the name of my folder is packages/

    Now we need to create two projects inside the packages/, one for the backend and other for the frontend.

  • Inside packages/ run npx create-mf-app

    create-mf-app Creates a Module Federation application, API server, or library based on one of multiple different templates. These projects are not production complete. They are designed as lightweight projects that can be used to quickly prototype a new feature or library.

  • After running the command, you will be prompted with a series of questions to select the template you want to start with. First will be our backend app, I am naming it api-server, the template type will be API-Server with Express

  • Next we will create our frontend app. Inside packages/ run npx create-mf-app again and this time i am naming it client , the template type will be Application > React > TypeScript > Tailwind

  • Once both packages have been created, go back into the root directory and run yarn. This will install the modules needed by both api-server and client and maintain the dependencies centrally following the monorepo structure.

Installing the needed frontend and backend dependencies

  • Before installing the dependencies, we will need to initialize the typescript compiler in both the packages to get the tsconfig.json. We can do this by running npx tsc --init inside both api-server/ and client/

  • Inside client/tsconfig.json , find and uncomment "jsx": "preserve"

The preserve mode will keep the JSX as part of the output to be further consumed by another transform step (e.g. Babel). Additionally, the output will have a .jsx file extension.

  • Inside api-server run yarn add @trpc/server zod cors to get the needed dependencies

    • Cors doesn't come with types out of the box run yarn add @types/cors -D to install the types and save them as a dev dependency.
  • While we are at the server side, open api-server/package.json and add a new property "main": "index.ts"

  • We can also add ts-node-dev by running yarn add ts-node-dev, this when prefixed in our start script will continuously watch for changes and refire the server without having us do it manually every time we make a change.

  • This is how the api-server/package.json would be looking at this point:

{
  "name": "api-server",
  "version": "1.0.0",
  "license": "MIT",
  "main": "index.ts",
  "scripts": {
    "start": "ts-node-dev index.ts"
  },
  "dependencies": {
    "@trpc/server": "^10.9.0",
    "cors": "^2.8.5",
    "express": "^4.17.1",
    "zod": "^3.20.2"
  },
  "devDependencies": {
    "@types/cors": "^2.8.13",
    "@types/express": "^4.17.13",
    "ts-node": "^10.4.0",
    "ts-node-dev": "^2.0.0",
    "typescript": "^4.4.4"
  }
}
  • Moving to the frontend, inside client/ add the api-server as a dependency with the accurate version by running yarn add api-server@1.0.0

  • Next, run yarn add @trpc/client @trpc/react react-query @tanstack/react-query zod to get trpc client, react-query and zod dependencies.

  • That's about it with all the dependency setup. At this point, this is how the client/package.json should be looking:

{
  "name": "client",
  "version": "1.0.0",
  "scripts": {
    "build": "webpack --mode production",
    "build:dev": "webpack --mode development",
    "build:start": "cd dist && PORT=3000 npx serve",
    "start": "webpack serve --open --mode development",
    "start:live": "webpack serve --open --mode development --live-reload --hot"
  },
  "license": "MIT",
  "author": {
    "name": "Adeesh"
  },
  "devDependencies": {
    "@babel/core": "^7.15.8",
    "@babel/plugin-transform-runtime": "^7.15.8",
    "@babel/preset-env": "^7.15.8",
    "@babel/preset-react": "^7.14.5",
    "@babel/preset-typescript": "^7.10.4",
    "@types/react": "^17.0.2",
    "@types/react-dom": "^17.0.2",
    "autoprefixer": "^10.1.0",
    "babel-loader": "^8.2.2",
    "css-loader": "^6.3.0",
    "html-webpack-plugin": "^5.3.2",
    "postcss": "^8.2.1",
    "postcss-loader": "^4.1.0",
    "style-loader": "^3.3.0",
    "tailwindcss": "^2.0.2",
    "typescript": "^4.5.2",
    "webpack": "^5.57.1",
    "webpack-cli": "^4.9.0",
    "webpack-dev-server": "^4.3.1"
  },
  "dependencies": {
    "@tanstack/react-query": "^4.23.0",
    "@trpc/client": "^10.9.0",
    "@trpc/react": "^9.27.4",
    "@trpc/react-query": "^10.9.0",
    "api-server": "1.0.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-query": "^3.39.3",
    "zod": "^3.20.2"
  }
}

Backend source code setup

  • Add the following code to api-server/index.ts
import express from 'express';
import { initTRPC, inferAsyncReturnType } from '@trpc/server';
import * as trpcExpress from '@trpc/server/adapters/express';
import cors from 'cors';
import { z } from 'zod';

// Chat message interface
interface ChatMessage {
  user: string;
  message: string;
}

const messages: ChatMessage[] = [
  { user: 'user1', message: 'Hello' },
  { user: 'user2', message: 'Hi' },
];

const app = express();

// to allow communication between port 8080/server and 3000/client
app.use(cors());
const port = 8080;

// create an empty context | otherwise used for something like authentication
const createContext = ({
  req,
  res,
}: trpcExpress.CreateExpressContextOptions) => ({}); // no context
type Context = inferAsyncReturnType<typeof createContext>;

// initializing trpc
const t = initTRPC.context<Context>().create();
const router = t.router;
const publicProcedure = t.procedure;

// creating a router with procedure
const appRouter = router({
  greeting: publicProcedure.query(() => 'Hello from tRPC!'),
  getMessages: publicProcedure
    .input(z.number().default(10))
    .query(({ input }) => messages.slice(-input)), // get last 10 messages w.r.t default input
  addMessage: publicProcedure
    .input(
      z.object({
        user: z.string(),
        message: z.string(),
      })
    )
    .mutation(({ input }) => {
      messages.push(input);
      return input;
    }),
});

// exporting trpc types to maintain end-to-end types | to be used by client
export type AppRouter = typeof appRouter;

// middleware to intercept any requests coming for trpc
app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext,
  })
);

// standard REST endpoint
app.get('/hello', (req, res) => {
  res.send('Hello from api-server');
});

app.listen(port, () => {
  console.log(`api-server listening at http://localhost:${port}`);
});
  • Believe it or not, that is the entire backend code for this setup and simple chat mechanism demo.

Frontend source code setup

  • Create a new file client/src/trpc.ts with the following content:
import { createTRPCReact } from '@trpc/react-query';
import { AppRouter } from 'api-server';

export const trpc = createTRPCReact<AppRouter>();
  • Inside client/src/App.tsx these should be imports:
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { trpc } from './trpc';
import { httpBatchLink } from '@trpc/client';
import './index.scss';
  • The trpc.Provider and QueryClientProvider need to wrap our main application content so that the trpcClient and queryClient are both available to the app content for data caching and using the trpc for query and mutations.
const App = () => {
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: 'http://localhost:8080/trpc',
        }),
      ],
    })
  );
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <AppContent />
      </QueryClientProvider>
    </trpc.Provider>
  );
};

ReactDOM.render(<App />, document.getElementById('app'));
  • Once the App is ready, we can create the AppContent to demonstrate the usage of tRPC
// Manages cache on react-query side
const queryClient = new QueryClient();

const AppContent = () => {
  // states to maintain a new user and message
  const [user, setUser] = useState('');
  const [message, setMessage] = useState('');

  // querying the greeting prodecure
  const greeting = trpc.greeting.useQuery();

  // querying the getMessages procedure, which by default returns last 10 messages
  const getLastTenMessages = trpc.getMessages.useQuery();

  // querying the getMessages procedure with an argument, which returns last message
  const getLastMessage = trpc.getMessages.useQuery(1);

  // create and addMessage procedure call which can be used to mutate data
  const newMessage = trpc.addMessage.useMutation();
  const addMessage = () => {
    newMessage.mutate(
      {
        user: user,
        message: message,
      },
      {
        onSuccess: () => {
          queryClient.invalidateQueries();
        },
      }
    );
  };

  return (
    <div className='mt-10 text-3xl mx-auto max-w-6xl'>
      <div>{JSON.stringify(greeting.data)}</div>
      <br />
      <p className='text-red-400 underline mb-2'>Last 10 Messages</p>
      <div>
        {(getLastTenMessages.data ?? []).map((row) => (
          <div key={row.message}>{JSON.stringify(row)}</div>
        ))}
      </div>
      <br />
      <p className='text-red-400 underline mb-2'>Last Message</p>
      <div>{JSON.stringify(getLastMessage.data)}</div>
      <br />
      <p className='text-red-400 underline mb-2'>Add New Message</p>
      <div className='mt-10 mb-5'>
        <input
          type='text'
          value={user}
          onChange={(e) => setUser(e.target.value)}
          className='p-5 border-2 border-gray-300 rounded-lg w-full'
          placeholder='User'
        />
        <input
          type='text'
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          className='p-5 border-2 border-gray-300 rounded-lg w-full'
          placeholder='Message'
        />
      </div>
      <button
        className='border hover:bg-green-400 bg-green-700 text-white rounded-lg px-2'
        onClick={addMessage}
      >
        Add
      </button>
    </div>
  );
};
  • And that's all the code, run yarn start in the root and your application should fire up looking like this:

Conclusion

tRPC is a TypeScript remote procedure call framework designed for building scalable, high-performance applications. Its defining characteristics include its ability to define procedures that can be executed remotely, its seamless integration with TypeScript systems, and its support for real-time communication and data exchange. tRPC's use cases include applications that require real-time communication and data exchange between systems, as well as applications that require robust and scalable APIs. By using tRPC, developers can take advantage of its features to build fast and reliable applications, while also reducing the complexity and time involved in setting up a new project.

References

GitHub

Did you find this article valuable?

Support Adeesh Sharma by becoming a sponsor. Any amount is appreciated!