Telerik blogs

Real-time updates can be a great way to keep all subscribed clients up to date with the latest changes. In this tutorial, we explore how to implement real-time functionality using TRPC.io and WebSockets to notify all subscribed users.

TRPC.io is a framework-agnostic library that can be used to create typesafe APIs. It comprises client and server libraries that are responsible for creating server API endpoints and consuming them from the client side.

A big advantage of TRPC.io is that types are shared between the server and the client. The TypeScript compiler immediately highlights any inconsistencies in the data sent between them.

In this tutorial, we will explore how to implement real-time functionality using TRPC.io.

Project Setup

For this project, we need to create an application that will consist of a frontend and a backend. For the client-side, we will use React with Vite. As for the backend, TRPC can be used with frameworks like Express or Fastify via adapters, but it also offers a way to run a standalone server. We will use the standalone approach.

To code along, you can follow the project setup instructions below, or just run the following commands to clone the starter repository and switch to the start branch.

$ git clone https://github.com/ThomasFindlay/real-time-functionality-with-trpcio
$ cd https://github.com/ThomasFindlay/real-time-functionality-with-trpcio
$ git checkout start

Make sure to install dependencies in both client and server directories and then run the npm run dev command in each directory.

If you’re not cloning the starter repo, create a new folder for the project. Head to your terminal and execute the commands below.

$ mkdir real-time-functionality-with-trpcio
$ cd real-time-functionality-with-trpcio

The real-time-functionality-with-trpcio folder will comprise two directories—client and server.

Client Setup

We will use Vite to scaffold a new React project with TypeScript. Just run the commands below in the real-time-functionality-with-trpcio folder.

$ npm create vite@latest client -- --template react-ts
$ cd client
$ npm install @trpc/client

Besides creating a new project, we also install the @trpc/client library. We will use it in our client app to perform API requests to the backend. Let’s set it up next.

Server Setup

There are a few steps we need to take. First, let’s initialize the backend project with the npm init command.

$ mkdir server
$ cd server
$ npm init 

npm init will ask you a few questions. You can skip all of them by pressing the enter key.

Several dependencies are needed for the server:

  • @trpc/server – Server-side TRPC functionality for creating routers
  • ws – WebSocket client and server implementation
  • zod – Schema validation
  • cors – Configure Access-Control-Allow-Origin headers
  • typescript – Initialize the tsconfig.json file
  • mitt – Event emitter library
  • nodemon & ts-node – Run the server in the development mode

Run the following commands next:

$ npm install @trpc/server cors zod ws mitt
$ npm install nodemon ts-node typescript @types/cors @types/ws -D
$ npx tsc --init
$ npx gitignore node

Besides installing dependencies, we also create a tsconfig.json file using the tsc --init command and create a .gitignore file.

Next, we need to update the package.json file to add a dev command that will run the backend. Replace the “scripts” section with the code below.

server/package.json

"scripts": {
  "dev": "nodemon src/index.ts"
},

Now that the projects are configured, both can be run using the npm run dev command. Note that you need to run it in each project—client and server. If you run the dev command for the server, you will most likely see an error, as we don’t have the src/index.ts file yet. We will add it in the next section.

Backend Server with TRPC.io

Let’s start by creating a file called trpc.ts where we will configure a TRPC instance and export a router and a public procedure. A procedure basically means an API endpoint, which can be a query, mutation or subscription.

server/src/trpc.ts

import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;

In this tutorial, we will learn how to create live subscriptions using TRPC.io. We are going to implement functionality that will allow clients to subscribe to an endpoint, receive a new quote every 5 seconds, and render it in the UI.

However, before we do that, let’s first implement a query endpoint that will return a quote from a third-party API.

server/src/quote/quote.router.ts

import { publicProcedure, router } from "../trpc";
import { z } from "zod";

const quoteSchema = z.object({
  id: z.number(),
  quote: z.string(),
  author: z.string(),
});

type Quote = z.infer<typeof quoteSchema>;

export const quoteRouter = router({
  getRandomQuote: publicProcedure.query(async (): Promise<Quote> => {
    const response = await fetch("https://dummyjson.com/quotes/random", {
      headers: {
        "Content-Type": "application/json",
      },
    });
    const result = await response.json();

    return quoteSchema.parse(result);
  }),
});

In the quote.router.ts file, we have the quoteRouter with one query procedure—getRandomQuote. It calls the dummyjson API and fetches a random quote. After the result is received, it is validated using the quoteSchema. Data returned using fetch is of type unknown, so it’s a good practice to validate the data and narrow down its type.

Now that we have the quoteRouter configured, let’s create the entry file for the backend that will contain configuration for a standalone TRPC server.

server/src/index.ts

import { createHTTPServer } from "@trpc/server/adapters/standalone";
import { quoteRouter } from "./quote/quote.router";
import { router } from "./trpc";
import cors from "cors";

const PORT = 3000;
const appRouter = router({
  quotes: quoteRouter,
});

export type AppRouter = typeof appRouter;

const { server, listen } = createHTTPServer({
  middleware: cors({
    origin: "http://localhost:5173",
  }),
  router: appRouter,
});

server.on("listening", () => {
  console.log(`Server listening on port ${PORT}`);
});

listen(PORT);

We use the cors package to allow requests from a different origin. Note that we explicitly specify that the only allowed origin is http://localhost:5173. In development mode, it’s fine to allow any origin to access your server, but for production, it’s important to ensure only specific clients can do that.

Now, we can start the server by running npm run dev command in the server directory.

Consuming TRPC Endpoints in React

The backend utilizes the @trpc/server package to configure the server and API endpoints. On the client side, the @trpc/client package needs to be used instead. We need to set up a TRPC proxy client that will point to the http://localhost:3000 URL, as that’s the origin of our server. Let’s create the TRPC client in a file called api.ts.

client/src/api/api.ts

import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import { type AppRouter } from "../../../server/src/index";

export const client = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: "http://localhost:3000",
    }),
  ],
});

TRPC’s core offering is end-to-end typesafe APIs, which is achieved by importing the AppRouter type from the server directory and passing it to createTRPCProxyClient. Thanks to that, we get type hints with autocompletion for the server endpoints on the client side and TS compiler errors if the data on the client side does not match the API endpoints.

What’s more, if there are any changes to the data shape on the server side, type information about that will be propagated to the client code, thus reducing the chances of accidental bugs.

Now that the TRPC client is ready, let’s use it to consume the getRandomQuote query. We will do so by adding a new function called fetchRandomQuote in the quote.api.ts file.

client/src/api/quote.api.ts

import { client } from "./api";

export const fetchRandomQuote = () => {
  return client.quotes.getRandomQuote.query();
};

Last but not least, let’s update the App component. It will fetch and display a random quote.

client/src/App.tsx

import { useState, useEffect } from "react";
import "./App.css";
import { fetchRandomQuote } from "./api/quote.api";

type Quote = {
  id: number;
  quote: string;
  author: string;
};

function App() {
  const [randomQuote, setRandomQuote] = useState<Quote | null>(null);

  useEffect(() => {
    (async () => {
      const quote = await fetchRandomQuote();
      setRandomQuote(quote);
    })();
  }, []);

  return (
    <div>
      <p>Quote:</p>
      {randomQuote ? (
        <div>
          <p>{randomQuote.quote}</p>
          <p>{randomQuote.author}</p>
        </div>
      ) : null}
    </div>
  );
}

export default App;

The image below shows what the UI should look like. The app should show a quote and its author.

Random Quote

WebSockets with TRPC.io

So far, we implemented functionality to fetch a random quote using TRPC.io. Now, let’s add some real-time functionality to the mix. First, we need to modify the quote.router.ts file in our backend.

server/src/quote/quote.router.ts

import mitt from "mitt";
import { publicProcedure, router } from "../trpc";
import { z } from "zod";
import { observable } from "@trpc/server/observable";

const quoteSchema = z.object({
  id: z.number(),
  quote: z.string(),
  author: z.string(),
});

type Quote = z.infer<typeof quoteSchema>;

const fetchRandomQuote = async () => {
  const response = await fetch("https://dummyjson.com/quotes/random", {
    headers: {
      "Content-Type": "application/json",
    },
  });
  const result = await response.json();
  return quoteSchema.parse(result);
};

const quoteEventEmitter = mitt<{
  "on-random-quote": Quote;
}>();

export const quoteRouter = router({
  getRandomQuote: publicProcedure.query(async (): Promise<Quote> => {
    return fetchRandomQuote();
  }),
  onNewRandomQuote: publicProcedure.subscription(() => {
    return observable<Quote>(emit => {
      const onNewRandomQuote = (data: Quote) => {
        emit.next(data);
      };

      (async () => {
        const quote = await fetchRandomQuote();
        quoteEventEmitter.emit("on-random-quote", quote);
      })();

      quoteEventEmitter.on("on-random-quote", onNewRandomQuote);
    });
  }),
});

setInterval(async () => {
  const quote = await fetchRandomQuote();
  quoteEventEmitter.emit("on-random-quote", quote);
}, 5000);

The first change is that we extracted the quote fetching logic to a separate function fetchRandomQuote, so we can reuse it.

const fetchRandomQuote = async () => {
  const response = await fetch("https://dummyjson.com/quotes/random", {
    headers: {
      "Content-Type": "application/json",
    },
  });
  const result = await response.json();
  return quoteSchema.parse(result);
};

Further, the mitt library is used to create an event emitter with one event—on-random-quote.

const quoteEventEmitter = mitt<{
  "on-random-quote": Quote;
}>();

The event emitter is used to trigger the broadcasting to all subscribed clients when a new quote is fetched.

The onNewRandomQuote procedure is a subscription which returns an observable that is executed every time a new client subscribes to updates.

onNewRandomQuote: publicProcedure.subscription(() => {
  return observable<Quote>(emit => {
    const onNewRandomQuote = (data: Quote) => {
      emit.next(data);
    };

    (async () => {
      const quote = await fetchRandomQuote();
      quoteEventEmitter.emit("on-random-quote", quote);
    })();

    quoteEventEmitter.on("on-random-quote", onNewRandomQuote);
  });
}),

Finally, at the end, we have the interval that fetches a new quote every 5 seconds and uses the quoteEventEmitter to send the fetched quote to all subscribers.

setInterval(async () => {
  const quote = await fetchRandomQuote();
  quoteEventEmitter.emit("on-random-quote", quote);
}, 5000);

The subscription is ready, but we don’t have a WebSocket server set up yet. Let’s configure it in the index.ts file.

server/src/index.ts

import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { createHTTPServer } from "@trpc/server/adapters/standalone";
import { quoteRouter } from "./quote/quote.router";
import { router } from "./trpc";
import cors from "cors";
import ws from "ws";

const PORT = 3000;

const wss = new ws.Server({
  port: 3001,
});

const appRouter = router({
  quotes: quoteRouter,
});

const handler = applyWSSHandler({ wss, router: appRouter });

export type AppRouter = typeof appRouter;

const { server, listen } = createHTTPServer({
  middleware: cors({
    origin: "http://localhost:5173",
  }),
  router: appRouter,
});

server.on("listening", () => {
  console.log(`Server listening on port ${PORT}`);
});

listen(PORT);

process.on("SIGTERM", () => {
  console.log("SIGTERM");
  handler.broadcastReconnectNotification();
  wss.close();
});

The WebSocket server will run on port 3001. On line 18, the applyWSSHandler is used to combine the WebSocket server with TRPC routes and forward messages to appropriate subscription procedures, such as onNewRandomQuote which we added a moment ago.

That’s all we had to do for the backend. Now, let’s take care of the frontend. First, we need to modify the api.ts file. At the moment, it contains a TRPC proxy client with the httpBatchLink, which is responsible for performing API requests. Now, we need to configure a WebSocket client and hook it up with the TRPC proxy client. A WebSocket client can be created using the createWSClient method. It can then be passed to the wsLink method, which is an equivalent of the httpBatchLink, but for WebSockets.

client/src/api/api.ts

import {
  createTRPCProxyClient,
  createWSClient,
  httpBatchLink,
  splitLink,
  wsLink,
} from "@trpc/client";
import { type AppRouter } from "../../../server/src/index";

const wsClient = createWSClient({
  url: `ws://localhost:3001`,
});

export const client = createTRPCProxyClient<AppRouter>({
  links: [
    splitLink({
      condition(op) {
        return op.type === "subscription";
      },
      true: wsLink({
        client: wsClient,
      }),
      false: httpBatchLink({
        url: "http://localhost:3000",
      }),
    }),
  ],
});

Note how we use the splitLink to tell TRPC which link it should use based on the operation’s type. wsLink will be used for subscription procedures and httpBatchLink for everything else. Next, let’s add a new method to the quote.api.ts file.

client/src/api/quote.api.ts

import { client } from "./api";

export const fetchRandomQuote = () => {
  return client.quotes.getRandomQuote.query();
};

export type SubscribeToRandomQuotesParams = Parameters<
  (typeof client)["quotes"]["onNewRandomQuote"]["subscribe"]
>;

export const subscribeToRandomQuotes = (
  args: SubscribeToRandomQuotesParams[0],
  config: SubscribeToRandomQuotesParams[1]
) => {
  return client.quotes.onNewRandomQuote.subscribe(args, config);
};

The subscribeToRandomQuotes method returns a subscription to the onNewRandomQuote procedure.

Finally, we can update the App component and add logic to subscribe to random quotes to display a new quote every 5 seconds.

client/src/App.tsx

import { useState, useEffect } from "react";
import "./App.css";
import { fetchRandomQuote, subscribeToRandomQuotes } from "./api/quote.api";

type Quote = {
  id: number;
  quote: string;
  author: string;
};

function App() {
  const [randomQuote, setRandomQuote] = useState<Quote | null>(null);
  const [latestRandomQuote, setLatestRandomQuote] = useState<Quote | null>(
    null
  );

  useEffect(() => {
    (async () => {
      const quote = await fetchRandomQuote();
      setRandomQuote(quote);
    })();

    const subscription = subscribeToRandomQuotes(undefined, {
      onData(data: Quote) {
        setLatestRandomQuote(data);
      },
    });

    return () => {
      subscription.unsubscribe();
    };
  }, []);

  return (
    <div>
      <p>Quote:</p>
      {randomQuote ? (
        <div>
          <p>{randomQuote.quote}</p>
          <p>{randomQuote.author}</p>
        </div>
      ) : null}

      <hr />

      <p>Quote from subscription:</p>
      {latestRandomQuote ? (
        <div>
          <p>{latestRandomQuote.quote}</p>
          <p>{latestRandomQuote.author}</p>
        </div>
      ) : null}
    </div>
  );
}

export default App;

In the useEffect we subscribe to new random quotes by calling the subscribeToRandomQuotes method. The setLatestRandomQuote updates the latest quote when a new one is sent. The GIF below shows what the UI should look like.

Real-Time Random Quote Subscription

Conclusion

In this tutorial, we explored how to use TRPC.io to implement a typesafe API and real-time functionality. We created an application that receives and displays a new quote every few seconds using TRPC.io with WebSockets. If you are up for a challenge, you can expand the application we built to allow users to create quotes themselves and broadcast them to others.

Happy coding!


Thomas Findlay-2
About the Author

Thomas Findlay

Thomas Findlay is a 5-star rated mentor, full-stack developer, consultant, technical writer and the author of “React - The Road To Enterprise” and “Vue - The Road To Enterprise.” He works with many different technologies such as JavaScript, Vue, React, React Native, Node.js, Python, PHP and more. Thomas has worked with developers and teams from beginner to advanced and helped them build and scale their applications and products. Check out his Codementor page, and you can also find him on Twitter.

Related Posts

Comments

Comments are disabled in preview mode.