
Hello, world!
В этом туториале мы разработаем простое типобезопасное (typesafe) клиент-серверное (fullstack) приложение с помощью tRPC, React и Express.
tRPC позволяет разрабатывать полностью безопасные с точки зрения типов API для клиент-серверных приложений (предпочтительной является архитектура монорепозитория). Это посредник между сервером и клиентом, позволяющий им использовать один маршрутизатор (роутер) для обработки запросов HTTP. Использование одного роутера, в свою очередь, обуславливает возможность автоматического вывода типов (type inference) входящих и исходящих данных (input/output), что особенно актуально для клиента и позволяет избежать дублирования типов или использования общих (shared) типов.
Руководство по tRPC находится в процессе подготовки — следите за обновлениями ?
Для тех, кого интересует только код, вот ссылка на соответствующий репозиторий.
Интересно? Тогда прошу под кат.
Подготовка и настройка проекта
Функционал нашего приложения будет следующим:
- на сервере хранится массив с данными пользователей;
- на сервере есть конечные точки (endpoints) для:
- получения всех пользователей;
- получения одного пользователя по идентификатору;
- создания нового пользователя;
- клиент запрашивает всех пользователей и рендерит список их имен;
- на клиенте есть форма для запроса одного пользователя по ID;
- на клиенте есть форма для создания нового пользователя.
Как видите, все очень просто. Давайте это реализуем.
Архитектура монорепозитория предполагает, что код клиента и сервера "живет" в одной директории (репозитории).
Создаем корневую директорию:
mkdir trpc-fullstack-app cd trpc-fullstack-app
Создаем директорию для сервера:
mkdir server cd server
Обратите внимание: для работы с зависимостями будет использоваться Yarn.
Инициализируем проект Node.js:
yarn init -yp
Устанавливаем основные зависимости:
yarn add express cors
Поскольку клиент и сервер будут иметь разные источники (origins) (будут запускаться на разных портах), "общение" между ними будет блокироваться CORS. Пакет cors позволяет настраивать эту политику.
Устанавливаем зависимости для разработки:
yarn add -D typescript @types/express @types/cors
Создаем файл tsconfig.json следующего содержания:
{ "compilerOptions": { "allowJs": false, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "module": "esnext", "moduleResolution": "node", // директория сборки "outDir": "./dist", // директория исходников "rootDir": "./src", "skipLibCheck": true, "strict": true, "target": "es2019" } }
Редактируем файл package.json:
{ // ... // основной файл сервера "main": "dist/index.js", // модули ES "type": "module", "scripts": { // компиляция TS в JS с наблюдением за изменениями файлов "ts:watch": "tsc -w", // запуск сервера с перезагрузкой после изменений "node:dev": "nodemon", // одновременное выполнение команд // мы установим concurrently на верхнем уровне "start": "concurrently \"yarn ts:watch\" \"yarn node:dev\"", // производственная сборка "build": "tsc --build && node dist/index.js" } }
Создаем директорию src и дальше работаем с ней.
Создаем файл index.ts следующего содержания:
import express from 'express' import cors from 'cors' const app = express() app.use(cors()) // адрес сервера: http://localhost:4000 app.listen(4000, () => { console.log('Server running on port 4000') })
Определяем тип пользователя в файле users/types.ts:
export type User = { id: string name: string }
Создаем массив пользователей в файле users/db.ts:
import type { User } from './types' export const users: User[] = [ { id: '0', name: 'John Doe', }, { id: '1', name: 'Richard Roe', }, ]
Возвращаемся в корневую директорию и создаем шаблон клиента с помощью Vite:
# client - название проекта/директории # react-ts - используемый шаблон yarn create vite client --template react-ts
Vite автоматически настраивает все необходимое, нашего участия в этом процессе не требуется ?
Обратите внимание: клиент будет запускаться по адресу http://localhost:5173
Находясь в корневой директории, инициализируем проект Node.js устанавливаем concurrently:
yarn init -yp yarn add concurrently
Определяем в package.json команду для одновременного запуска сервера и клиента:
{ // ... "scripts": { "dev": "concurrently \"yarn --cwd ./server start\" \"yarn --cwd ./client dev\"" } }
Это все, что требуется для подготовки и настройки проекта. Переходим к доработке сервера.
Сервер
Нам потребуется еще 2 пакета:
yarn add @trpc/server zod
- @trpc/server — пакет для разработки конечных точек и роутеров;
- zod — пакет для валидации данных.
Далее работаем с директорией src.
Создаем файл trpc.ts с кодом инициализации tRPC:
import { initTRPC } from '@trpc/server' import type { Context } from './context' const t = initTRPC.context<Context>().create() export const router = t.router export const publicProcedure = t.procedure
Определяем контекст tRPC в файле context.ts:
import { inferAsyncReturnType } from '@trpc/server' import * as trpcExpress from '@trpc/server/adapters/express' const createContext = ({ req, res, }: trpcExpress.CreateExpressContextOptions) => { return {} } export type Context = inferAsyncReturnType<typeof createContext> export default createContext
Определяем корневой роутер/роутер приложения tRPC в файле router.ts:
import { router } from './trpc.js' import userRouter from './user/router.js' const appRouter = router({ user: userRouter, }) export default appRouter
Для подключения tRPC к серверу используется обработчик запросов или адаптер. Редактируем файл index.ts:
// ... import * as trpcExpress from '@trpc/server/adapters/express' import appRouter from './router.js' import createContext from './context.js' // ... app.use(cors()) app.use( // суффикс пути // получаем http://localhost:4000/trpc '/trpc', trpcExpress.createExpressMiddleware({ router: appRouter, createContext, }), ) // ... // обратите внимание: экспортируется не сам роутер, а только его тип export type AppRouter = typeof appRouter
Наконец, определяем конечные точки пользователей в файле users/router.ts:
import { z } from 'zod' import { router, publicProcedure } from '../trpc.js' import { users } from './db.js' import type { User } from './types' import { TRPCError } from '@trpc/server' const userRouter = router({ // обработка запроса на получение всех пользователей // выполняем запрос (query) getUsers: publicProcedure.query(() => { // просто возвращаем массив return users }), // обработка запроса на получение одного пользователя по ID getUserById: publicProcedure // валидация тела запроса // ID должен быть строкой .input((val: unknown) => { if (typeof val === 'string') return val throw new TRPCError({ code: 'BAD_REQUEST', message: `Invalid input: ${typeof val}`, }) }) .query((req) => { const { input } = req // ищем пользователя const user = users.find((u) => u.id === input) // если не нашли, выбрасываем исключение if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: `User with ID ${input} not found`, }) } // если нашли, возвращаем его return user }), // обработка создания нового пользователя createUser: publicProcedure // тело запроса должно представлять собой объект с полем `name`, // значением которого должна быть строка .input(z.object({ name: z.string() })) // выполняем мутацию .mutation((req) => { const { input } = req // создаем пользователя const user: User = { id: `${Date.now().toString(36).slice(2)}`, name: input.name, } // добавляем его в массив users.push(user) // и возвращаем return user }), }) export default userRouter
Финальная структура директории server:
- node_modules - src - user - db.ts - router.ts - types.ts - context.ts - index.ts - router.ts - trpc.ts - package.json - tsconfig.json - yarn.lock
Наш сервер полностью готов к обработке запросов клиента.
Клиент
Здесь нам также потребуется еще 2 пакета.
# client yarn add @trpc/client @trpc/server
Возможно, мы могли установить @trpc/server на верхнем уровне ?
Далее работаем с директорией src.
Создаем файл trpc.ts с кодом инициализации tRPC:
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client' // здесь могут возникнуть проблемы при использовании синонимов путей (type aliases) import { AppRouter } from '../../server/src/index' export const trpc = createTRPCProxyClient<AppRouter>({ links: [ httpBatchLink({ url: 'http://localhost:4000/trpc', }), ], })
Для начала давайте просто выведем список пользователь в консоль инструментов разработчика в браузере. Редактируем файл App.tsx следующим образом:
import { useEffect } from 'react' import { trpc } from './trpc' function App() { useEffect(() => { trpc.user.getUsers.query() .then(console.log) .catch(console.error) }, []) return ( <div></div> ) } export default App
Запускаем приложение. Это можно сделать 2 способами:
- выполнить команду
yarn devиз корневой директории; - выполнить команду
yarn startиз директорииserverи командуyarn devиз директорииclient.
Результат:

Многим React-разработчикам (и мне, в том числе) нравится библиотека React Query, позволяющая легко получать, кэшировать и модифицировать данные. К счастью, tRPC предоставляет абстракцию над React Query. Устанавливаем еще 2 пакета:
yarn add @tanstack/react-query @trpc/react-query
Редактируем файл trpc.ts:
import { createTRPCReact, httpBatchLink } from '@trpc/react-query' import { AppRouter } from '../../server/src/index' export const trpc = createTRPCReact<AppRouter>() export const trpcClient = trpc.createClient({ links: [ httpBatchLink({ url: 'http://localhost:4000/trpc', }), ], })
Редактируем файл main.tsx:
// ... import App from './App' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { trpc, trpcClient } from './trpc' const queryClient = new QueryClient() ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( <React.StrictMode> <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> </trpc.Provider> </React.StrictMode>, )
Получим пользователей и отрендерим их имена в App.tsx:
function App { const { data: usersData, isLoading: isUsersLoading, } = trpc.user.getUsers.useQuery() if (isUsersLoading) return <div>Loading...</div> return ( <div> <ul> {(usersData ?? []).map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> </div> ) }
Результат:

Добавляем форму для получения одного пользователя по ID:
function App() { // ... const [userId, setUserId] = useState('0') const { data: userData, isLoading: isUserLoading, error, } = trpc.user.getUserById.useQuery(userId, { retry: false, refetchOnWindowFocus: false, }) if (isUsersLoading || isUserLoading) return <div>Loading...</div> const getUserById: React.FormEventHandler = (e) => { e.preventDefault() const input = (e.target as HTMLFormElement).elements[0] as HTMLInputElement const userId = input.value.replace(/\s+/g, '') if (userId) { // обновление состояния ID пользователя приводит к выполнению нового/повторного запроса setUserId(userId) } } return ( <div> {/* ... */} <div> <form onSubmit={getUserById}> <label> Get user by ID <input type='text' defaultValue={userId} /> </label> <button>Get</button> </form> {/* Если пользователь найден */} {userData && <div>{userData.name}</div>} {/* Если пользователь не найден */} {error && <div>{error.message}</div>} </div> </div> ) }
Результат:


Наконец, добавляем форму для создания нового пользователя:
function App() { const { data: usersData, isLoading: isUsersLoading, // метод для ручного повторного выполнения запроса refetch, } = trpc.user.getUsers.useQuery() // ... // Состояние для имени пользователя const [userName, setUserName] = useState('Some Body') // Мутация для создания пользователя const createUserMutation = trpc.user.createUser.useMutation({ // После выполнения мутации необходимо повторно запросить список пользователей onSuccess: () => refetch(), }) // ... // Обработка отправки формы для создания нового пользователя const createUser: React.FormEventHandler = (e) => { e.preventDefault() const name = userName.trim() if (name) { createUserMutation.mutate({ name }) setUserName('') } } return ( <div> {/* ... */} <form onSubmit={createUser}> <label> Create new user{' '} <input type='text' value={userName} onChange={(e) => setUserName(e.target.value)} /> </label> <button>Create</button> </form> </div> ) }
Результат:

На этом разработку нашего приложения можно считать завершенной.
Надеюсь, вы узнали что-то новое и не зря потратили время.
Happy coding!

