30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Next.js で始める GraphQL

この記事では、GraphQL を利用したアプリを Next.js で構築していきながら、GraphQL の初歩について書いていく。

GraphQL のクライアントもサーバも、Apollo を用いる。
また、できるだけ型安全に開発したいので、graphql-codegenで型定義ファイルを生成する方法も扱う。

利用しているライブラリのバージョンは以下の通り。

  • @apollo/client@3.5.10
  • @graphql-codegen/cli@2.6.2
  • @graphql-codegen/typed-document-node@2.2.7
  • @graphql-codegen/typescript-operations@2.3.4
  • @graphql-codegen/typescript-resolvers@2.5.4
  • @graphql-codegen/typescript@2.4.7
  • @types/node@17.0.21
  • @types/react@17.0.40
  • apollo-server-micro@3.6.4
  • concurrently@7.0.0
  • eslint-config-next@12.1.0
  • eslint@8.11.0
  • graphql@16.3.0
  • micro@9.3.4
  • next@12.1.0
  • react-dom@17.0.2
  • react@17.0.2
  • typescript@4.6.2

スキーマを定義する

まず、Next.js の環境を構築する。

$ yarn create next-app sample --ts

プロジェクトのルートディレクトリ(今回の例だとsample)にgraphqlディレクトリを作り、そのなかに以下の内容のschema.graphqlを作る。

type User {
  id: ID!
  name: String!
}

Userというデータ構造を定義している。Useridnameを持つ。末尾の!nullを許容しないことを意味している。

次に、クライアントがこのUserを取得するためのクエリのスキーマを定義する。
schema.graphqlに以下を追記する。

type Query {
  users: [User!]!
}

usersというクエリを定義しており、このクエリを使うとクライアントはUserの配列を得ることができる。

GraphQL サーバを構築する

今回は Next.js の API Routes を GraphQL サーバとして使うことにする。
そのためpages/api以下にファイルを作成することになるが、その前にまず、先程定義したスキーマに基づいて型定義ファイルを生成する。

ファイル生成に必要なライブラリをインストール。

$ yarn add graphql
$ yarn add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

続いて、以下の内容のgraphql/codegen-server.yamlを作る。

schema: './graphql/schema.graphql'
generates:
  ./graphql/dist/generated-server.ts:
    config:
      useIndexSignature: true
    plugins:
      - typescript
      - typescript-resolvers

この状態で以下のコマンドを実行すると、graphql/dist/generated-server.tsが生成される。

$ yarn run graphql-codegen --config graphql/codegen-server.yaml

このファイルを使うことで、型の恩恵を受けながら GraphQL サーバを開発することができる。

今回はmicroを使って GraphQL サーバを構築するので、そのために必要なライブラリをインストールする。

$ yarn add micro apollo-server-micro

以下の内容のpages/api/graphql.tsを作成する。

import { NextApiRequest, NextApiResponse } from "next";
import { ApolloServer } from "apollo-server-micro";
import { readFileSync } from "fs";
import { join } from "path";

import { Resolvers } from "../../graphql/dist/generated-server";

const path = join(process.cwd(), "graphql", "schema.graphql");
const typeDefs = readFileSync(path).toString("utf-8");

// スキーマと実際のデータ構造の紐付けを resolvers で行う
const resolvers: Resolvers = {};

const apolloServer = new ApolloServer({ typeDefs, resolvers });

const startServer = apolloServer.start();

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  await startServer;
  await apolloServer.createHandler({
    path: "/api/graphql",
  })(req, res);
}

export const config = {
  api: {
    bodyParser: false,
  },
};

スキーマ定義の際にusersというクエリを定義したが、実際にどのようなロジックでどのようなデータを返すのかは、まだどこにも定義されていない。
それを定義するのがリゾルバである。
今回はpages/api/graphql.tsにデータをハードコーディングするが、データベースから取得してもいいし、他のサービスの API から取得してもいい。

早速書いてみる。

const resolvers: Resolvers = {
  Query: {
    users: () => [{ id: "1" }], // Type Error
  },
};

上記のように書くと型エラーが出るが、これはnameが含まれていないため。
自動生成されたResolversを利用しているため、このように型の恩恵を受けることができる。
同様に、nametrueにするなど、型が間違っていてもエラーになる。
ただ、以下のように余計なプロパティがついていても型エラーにはならないので注意する。

const resolvers: Resolvers = {
  Query: {
    users: () => [{ id: "1", name: "Alice", foo: "bar" }],
  },
};

今回は以下のようにした。

const users = [
  { id: "1", name: "Alice" },
  { id: "2", name: "Bob" },
  { id: "3", name: "Carol" },
];

const resolvers: Resolvers = {
  Query: {
    users: () => users,
  },
};

これでサーバの構築は完了したので、クライアントの構築に移る。

GraphQL クライアントを構築する

サーバと同様、まずは型定義ファイルを生成するための作業を行う。

以下の内容のgraphql/query.graphqlを作る。

query getUsersName {
  users {
    name
  }
}

GraphQL では、どのようなデータを取得するのかをクライアント側が決める。
上記の例では、usersクエリを使ってnameのみを取得している。idは不要と判断し、取得していない。

コード生成のためのライブラリをインストール。

$ yarn add -D @graphql-codegen/typed-document-node @graphql-codegen/typescript-operations

graphql/codegen-client.yamlを作る。

schema: './graphql/schema.graphql'
documents: './graphql/*.graphql'
generates:
  ./graphql/dist/generated-client.ts:
    plugins:
      - typescript
      - typescript-operations
      - typed-document-node

この状態で以下のコマンドを実行すると、graphql/dist/generated-client.tsが生成される。

$ yarn run graphql-codegen --config graphql/codegen-client.yaml

これで型定義ファイルが生成されたので、実際に GraphQL クライアントを構築していく。
サーバと同様に Apollo を使うので、インストールする。

$ yarn add @apollo/client

そして、pages/index.tsxを以下のように書き換える。

import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  useQuery,
} from "@apollo/client";
import { GetUsersNameDocument } from "../graphql/dist/generated-client";

const client = new ApolloClient({
  uri: "http://localhost:3000/api/graphql",
  cache: new InMemoryCache(),
});

function Users() {
  const { loading, error, data } = useQuery(GetUsersNameDocument);

  if (loading) return <p>Loading...</p>;
  if (error || !data) return <p>Error</p>;

  return (
    <ul>
      {data.users.map((user, index: number) => {
        console.log(Object.getOwnPropertyNames(user)); // ['__typename', 'name']
        return <li key={index}>{user.name}</li>;
      })}
    </ul>
  );
}

export default function App() {
  return (
    <ApolloProvider client={client}>
      <Users />
    </ApolloProvider>
  );
}

$ yarn devを実行してhttp://localhost:3000/にアクセスすると、ユーザー名のリストが表示されているはず。
そして、Object.getOwnPropertyNames(user)の結果を見ると分かるように、idは取得されておらず、指定したデータのみを取得できていることが分かる。

watch オプションを使う

今の状態だと、スキーマ定義などが更新される度に、コマンドを実行して型定義ファイルを生成しないといけない。
graphql-codegenwatchオプションを使うことで、ファイル更新時に自動的に再生成されるようになる。

$ yarn run graphql-codegen --config graphql/codegen-server.yaml --watch
$ yarn run graphql-codegen --config graphql/codegen-client.yaml --watch

開発環境の起動時にこれらのコマンドを実行すると便利なので、npm scriptsを使って実現する。

まず、複数の npm script を実行させたいので、concurrentlyをインストールする。

$ yarn add -D concurrently

次に、package.jsonscriptsフィールドを以下のように書き換える。

  "scripts": {
    "dev": "concurrently \"yarn run generate-client --watch\" \"yarn run generate-server --watch\" \"next dev\"",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "generate-client": "graphql-codegen --config graphql/codegen-server.yaml",
    "generate-server": "graphql-codegen --config graphql/codegen-client.yaml"
  },

この状態で$ yarn devを実行すると、スキーマ定義やクエリを更新した際に、型定義ファイルも自動的に再生成される。

例えば、graphql/query.graphqlを以下のように書き換えると、graphql/dist/generated-clientも自動的に再生成される。

query getUsers {
  users {
    id
    name
  }
}

そのため、pages/index.tsxの以下の部分がエラーになる。

import { GetUsersNameDocument } from "../graphql/dist/generated-client"; // Error

GetUsersNameDocumentGetUsersDocumentに置換すると解決すると同時に、取得したuserのなかにidが含まれるようになる。

console.log(Object.getOwnPropertyNames(user)); // ['__typename', 'id', 'name']

これでアプリの構築は終わったが、このアプリを題材にして、GraphQL を使い方をもう少し見ていく。

一度のリクエストで複数のクエリを利用する

GraphQL のメリットとしてよく語られることのひとつに、一度のリクエストで必要なデータを取得できる、というものがある。
例として、UserだけでなくTeamというデータ構造も使うことになったケースについて考えてみる。

まず、スキーマにTeamを追加する。

# graphql/schema.graphql
type User {
  id: ID!
  name: String!
  teamName: String!
}

type Team {
  id: ID!
  name: String!
}

type Query {
  users: [User!]!
  teams: [Team!]!
}

pages/api/graphql.tsに型エラーが出ているはずなので、新しいスキーマ定義に合わせて修正する。

type Team = "Red" | "White";

const teams: { id: string; name: Team }[] = [
  { id: "1", name: "Red" },
  { id: "2", name: "White" },
];

type User = { id: string; name: string; teamName: Team };

const users: User[] = [
  { id: "1", name: "Alice", teamName: "Red" },
  { id: "2", name: "Bob", teamName: "Red" },
  { id: "3", name: "Carol", teamName: "White" },
];

const resolvers: Resolvers = {
  Query: {
    users: () => users,
    teams: () => teams,
  },
};

次にgraphql/query.graphqlにクエリを書いていく。
この際、getUsersと同じ要領でgetTeamsを書いてその 2 つを使ってもいいのだが、ひとつのクエリでまとめてUserTeamを取得することができる。

具体的には、graphql/query.graphqlに以下の内容を追記すればよい。

query getUsersAndTeams {
  users {
    id
    name
    teamName
  }
  teams {
    id
    name
  }
}

そうするとクライアントでGetUsersAndTeamsDocumentを使えるようになっているので、pages/index.tsxを以下のように書き換える。

import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  useQuery,
} from "@apollo/client";
import { GetUsersAndTeamsDocument } from "../graphql/dist/generated-client";

const client = new ApolloClient({
  uri: "http://localhost:3000/api/graphql",
  cache: new InMemoryCache(),
});

function UsersAndTeams() {
  const { loading, error, data } = useQuery(GetUsersAndTeamsDocument);

  if (loading) return <p>Loading...</p>;
  if (error || !data) return <p>Error</p>;

  const { users, teams } = data;

  return (
    <>
      <h1>Team List</h1>
      <ul>
        {teams.map(({ id, name }) => {
          return <li key={id}>{name}</li>;
        })}
      </ul>
      <h1>User List</h1>
      <ul>
        {users.map(({ id, name, teamName }) => {
          return (
            <li key={id}>
              <b>{name}</b> belong {teamName} team
            </li>
          );
        })}
      </ul>
    </>
  );
}

export default function App() {
  return (
    <ApolloProvider client={client}>
      <UsersAndTeams />
    </ApolloProvider>
  );
}

一度のリクエストでUserTeamの両方を取得できていることが分かる。

クエリ引数

クエリ引数を使うことで、特定の条件を満たしたデータのみを取得することもできる。

例として、スキーマ定義にuserというクエリを追加する。
引数としてnameを受け取り、それに基づいて特定のUserを返す。該当するUserが存在しないケースがあり得るので、!はつけていない。

# graphql/schema.graphql
type Query {
  users: [User!]!
  teams: [Team!]!
  user(name: String!): User
}

pages/api/graphql.tsのリゾルバに、以下のuserメソッドを追加する。
実装をみれば分かるが、渡されたnameと一致するユーザーが存在した場合はそのユーザーを、存在しなかった場合はnullを返す。

    user: (_, { name: specifiedName }) => {
      const user = users.find(({ name }) => name === specifiedName);
      return user || null;
    },

クライアントが使用するクエリを定義する。

# graphql/query.graphql
query getUser($name: String!) {
  user(name: $name) {
    id
    name
    teamName
  }
}

GetUserDocumentが使えるようになるので、pages/index.tsxを以下のように書き換える。

import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  useQuery,
} from "@apollo/client";
import { useState, memo } from "react";
import { GetUserDocument } from "../graphql/dist/generated-client";

const client = new ApolloClient({
  uri: "http://localhost:3000/api/graphql",
  cache: new InMemoryCache(),
});

const User = memo(function User({ specifiedName }: { specifiedName: string }) {
  const { loading, error, data } = useQuery(GetUserDocument, {
    variables: { name: specifiedName },
  });

  if (loading) return <p>Loading...</p>;
  if (error || !data) return <p>Error</p>;

  const { user } = data;

  if (!user) {
    return <div>user not found.</div>;
  }

  const { id, name, teamName } = user;

  return (
    <div>
      <span>id: {id}</span>
      <br />
      <span>name: {name}</span>
      <br />
      <span>team {teamName}</span>
    </div>
  );
});

function SearchUser() {
  const [text, setText] = useState("");
  const [specifiedName, setSpecifiedName] = useState("");

  return (
    <>
      <h1>Search User</h1>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          setSpecifiedName(text);
        }}
      >
        <input
          type="text"
          value={text}
          onChange={(e) => {
            setText(e.currentTarget.value);
          }}
        />
      </form>
      <User specifiedName={specifiedName} />
    </>
  );
}

export default function App() {
  return (
    <ApolloProvider client={client}>
      <SearchUser />
    </ApolloProvider>
  );
}

テキストボックスにユーザー名を入力して送信(エンター)すると、その結果が表示されるはず。

Mutation

ここまで見てきた内容は全て、GraphQL サーバからデータを取得するものだった。
最後に、データを操作する方法を紹介する。

データの取得にはQueryを使ってきたが、データの操作にはMutationを使う。

スキーマ定義、リゾルバ定義、クライアントが使うクエリを書く、クライアントの実装、という流れはこれまでと変わらない。

スキーマ定義

# graphql/schema.graphql に以下を追記
type Mutation {
  addUser(name: String!): User
}

リゾルバ定義

// pages/api/graphql.ts のリゾルバを更新
const addUser = (newUserName: string): User | null => {
  const user = users.find(({ name }) => name === newUserName);
  if (user) return null; // 同名のユーザーが存在した場合は null を返す
  const id = users.length + 1;
  const newUser: User = {
    id: String(id),
    name: newUserName,
    teamName: "White", // 実装が面倒なのでチームは必ず White にするようにした
  };
  users.push(newUser);
  return newUser;
};

const resolvers: Resolvers = {
  Query: {
    // 省略
  },
  Mutation: {
    addUser: (_, { name }) => addUser(name),
  },
};

クエリを書く

# graphql/query.graphql に以下を追記
mutation addUser($name: String!) {
  addUser(name: $name) {
    id
  }
}

クライアントの実装

pages/index.tsxAddUserを追加すれば完成。

function AddUser() {
  const [text, setText] = useState("");
  const [addUser, { loading, error, data }] = useMutation(AddUserDocument);

  const Status = () => {
    if (loading) return <p>Loading...</p>;
    if (error) return <p>Error</p>;
    if (data && !data.addUser) return <p>同名のユーザーが既に存在します</p>;
    if (!data) return null;
    return <p>登録が完了しました</p>;
  };

  return (
    <>
      <h1>Add User</h1>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          addUser({
            variables: {
              name: text,
            },
          });
        }}
      >
        <input
          type="text"
          value={text}
          onChange={(e) => {
            setText(e.currentTarget.value);
          }}
        />
      </form>

      <Status />
    </>
  );
}

export default function App() {
  return (
    <ApolloProvider client={client}>
      <SearchUser />
      <AddUser /> // これを追加
    </ApolloProvider>
  );
}

試しにAliceを追加しようとすると失敗するし(既にAliceが存在するため)、Daveを追加してからDaveを検索すると情報を取得できる。

Next.js で始める gRPC 通信

サーバ・クライアント間の通信を gRPC で行う場合、インターフェイスを定義した共通のファイルから、サーバとクライアント双方のコードを生成することができる。
この記事では、インターフェイスの定義ファイルを作成するところから始めて、gRPC を利用した単純なウェブアプリを作っていく。
gRPC についての概念的な説明などは扱わず、実際に手元で動くウェブアプリを作ることで、gRPC を使った開発についてイメージしやすくなることを意図している。

Next.js では API Routes を使って API サーバを作ることができるが、それを gRPC クライアントとして実装する。
そのため、リクエストの流れは以下のようになる。

Frontend == (REST) ==> API Routes == (gRPC) ==> gRPC Server

動作確認は Node.js のv16.13.2で行っており、利用しているライブラリのバージョンは以下の通り。

  • gRPC サーバ
    • @grpc/grpc-js@1.5.5
    • google-protobuf@3.19.4
    • grpc_tools_node_protoc_ts@5.3.2
    • grpc-tools@1.11.2
    • ts-node-dev@1.1.8
    • typescript@4.5.5
  • gRPC クライアント
    • @grpc/grpc-js@1.5.5
    • @types/node@17.0.17
    • @types/react@17.0.39
    • eslint-config-next@12.0.10
    • eslint@8.9.0
    • google-protobuf@3.19.4
    • grpc_tools_node_protoc_ts@5.3.2
    • grpc-tools@1.11.2
    • next@12.0.10
    • react-dom@17.0.2
    • react@17.0.2
    • typescript@4.5.5

proto ファイル

まずは、「proto ファイル」と呼ばれる、インターフェイスを定義したファイルを作成する。

プロジェクトのルートディレクトリにprotosディレクトリを作り、そのなかに以下の内容のuser.protoを作成する。

syntax = "proto3";

service UserManager {
  rpc get (UserRequest) returns (UserResponse) {}
}

message User {
  uint32 id = 1;
  string name = 2;
  bool is_admin = 3;
}

message UserRequest {
  uint32 id = 1;
}

message UserResponse {
  User user = 1;
}

UserManagerというサービスを定義しており、このサービスはgetという関数(プロシージャ)を持つ。
getはパラメータとしてUserRequestを受け取り、UserResponseを返す。

この proto ファイルからコードを生成して、クライアントやサーバの開発を行っていく。
この記事ではどちらも TypeScript で開発するが、他の言語を使ってもよいし、クライアントとサーバで言語を揃える必要もない。
gRPC がサポートしている言語なら、どの言語でも proto ファイルからコードを生成できる。

gRPC サーバの開発

プロジェクトのルートディレクトリにserverディレクトリを作り、そこで gRPC サーバの開発を行う。
まずは開発に必要なライブラリをインストールする。

$ mkdir server
$ cd server
$ yarn add @grpc/grpc-js google-protobuf
$ yarn add -D grpc-tools grpc_tools_node_protoc_ts

次に、codegenというディレクトリを作り、proto ファイルをコンパイルしてそこにコードを出力する。

$ mkdir codegen
$ yarn run grpc_tools_node_protoc --plugin=./node_modules/.bin/protoc-gen-ts --js_out=import_style=commonjs,binary:codegen --grpc_out=grpc_js:codegen --ts_out=grpc_js:codegen -I ../ ../protos/user.proto

以下のように 4 つのファイルが生成されていれば成功。

$ ls -1 codegen/protos
user_grpc_pb.d.ts
user_grpc_pb.js
user_pb.d.ts
user_pb.js

ここから実際にコードを書いていくので、TypeScript のセットアップを行う。

$ yarn add -D typescript
$ yarn run tsc --init

次に、src/index.tsを作成し、以下のように書く。

import {
  sendUnaryData,
  Server,
  ServerCredentials,
  ServerUnaryCall,
} from "@grpc/grpc-js";
import { UserManagerService } from "../codegen/protos/user_grpc_pb";
import { UserRequest, UserResponse, User } from "../codegen/protos/user_pb";

// 実際には DB のような永続層から取得するはず
const users = new Map([
  [1, { id: 1, name: "Alice", isAdmin: true }],
  [2, { id: 2, name: "Bob", isAdmin: false }],
  [3, { id: 3, name: "Carol", isAdmin: false }],
]);

function get(
  call: ServerUnaryCall<UserRequest, UserResponse>,
  callback: sendUnaryData<UserResponse>
) {
  const requestId = call.request.getId();
  const targetedUser = users.get(requestId);

  const response = new UserResponse();
  if (!targetedUser) {
    throw new Error("User is not found.");
  }

  const user = new User();
  user.setId(targetedUser.id);
  user.setName(targetedUser.name);
  user.setIsAdmin(targetedUser.isAdmin);

  response.setUser(user);
  callback(null, response);
}

function startServer() {
  const server = new Server();
  server.addService(UserManagerService, { get });
  server.bindAsync(
    "0.0.0.0:50051",
    ServerCredentials.createInsecure(),
    (error, port) => {
      if (error) {
        console.error(error);
      }
      server.start();
      console.log(`server start listing on port ${port}`);
    }
  );
}

startServer();

先程生成したuser_grpc_pbuser_pbを import し、それを使ってコードを書いている。

最後に、ts-node-devを使ってサーバを起動する。

$ yarn add -D ts-node-dev
$ yarn run ts-node-dev src/index.ts

server start listing on port 50051と表示されれば成功。

gRPC クライアントの開発

クライアント側は Next.js を使うため、プロジェクトのルートディレクトリに戻って以下のコマンドを実行する。

$ yarn create next-app client --ts

この時点で、以下のようなディレクトリ構成になっているはず。

$ tree ./ -L 2
./
├── client
│   ├── README.md
│   ├── next-env.d.ts
│   ├── next.config.js
│   ├── node_modules
│   ├── package.json
│   ├── pages
│   ├── public
│   ├── styles
│   ├── tsconfig.json
│   └── yarn.lock
├── protos
│   └── user.proto
└── server
    ├── codegen
    ├── node_modules
    ├── package.json
    ├── src
    ├── tsconfig.json
    └── yarn.lock

以降は、clientに移動して Next.js での開発を行う。

まずはサーバのときと同様、gRPC 関連のライブラリのインストールと、proto ファイルのコンパイルを行う。

$ yarn add @grpc/grpc-js google-protobuf
$ yarn add -D grpc-tools grpc_tools_node_protoc_ts
$ mkdir codegen
$ yarn run grpc_tools_node_protoc --plugin=./node_modules/.bin/protoc-gen-ts --js_out=import_style=commonjs,binary:codegen --grpc_out=grpc_js:codegen --ts_out=grpc_js:codegen -I ../ ../protos/user.proto

続いて、以下の内容のpages/api/user.tsを作る。これが gRPC クライアントとして機能する。

import type { NextApiRequest, NextApiResponse } from "next";
import { credentials, ServiceError } from "@grpc/grpc-js";

import { UserManagerClient } from "../../codegen/protos/user_grpc_pb";
import { UserRequest, UserResponse } from "../../codegen/protos/user_pb";

const Request = new UserRequest();
const Client = new UserManagerClient(
  "localhost:50051",
  credentials.createInsecure()
);

export type UserApiResponse =
  | { ok: true; user: UserResponse.AsObject["user"] }
  | { ok: false; error: ServiceError };

export default function handler(
  apiReq: NextApiRequest,
  apiRes: NextApiResponse<UserApiResponse>
) {
  const { id } = JSON.parse(apiReq.body);
  Request.setId(id);
  Client.get(Request, (grpcErr, grpcRes) => {
    if (grpcErr) {
      apiRes.status(500).json({ ok: false, error: grpcErr });
    } else {
      const { user } = grpcRes.toObject();
      apiRes.status(200).json({ ok: true, user });
    }
  });
}

gRPC サーバと同様、proto ファイルから生成されたコードを使って実装している。

最後に、pages/index.tsxを編集して UI を作る。

import type { NextPage } from "next";
import { useState, Fragment, ChangeEvent } from "react";

import type { UserApiResponse } from "./api/user";

const App: NextPage = () => {
  const [result, setResult] = useState<string>("");
  const [selectedId, setSelectedId] = useState<number>();

  const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
    const id = Number(e.currentTarget.value);
    setSelectedId(id);

    const res = await fetch("/api/user", {
      method: "POST",
      body: JSON.stringify({ id }),
    });

    const json: UserApiResponse = await res.json();

    if (json.ok) {
      const { user } = json;
      setResult(JSON.stringify(user));
    } else {
      const { code, details } = json.error;
      setResult(`Error! ${code}: ${details}`);
    }
  };

  return (
    <div>
      {[...Array(3)].map((_, index) => {
        const id = index + 1;
        return (
          <Fragment key={id}>
            <input
              type="radio"
              value={id}
              onChange={handleChange}
              checked={id === selectedId}
            />
            {id}{" "}
          </Fragment>
        );
      })}
      <p>{result}</p>
    </div>
  );
};

export default App;

gRPC サーバが起動している状態で$ yarn run devしてhttp://localhost:3000/にアクセスすると、選択したチェックボックスに応じて表示が変わる。
例えば1を選択すると以下が表示されるはず。

{"id":1,"name":"Alice","isAdmin":true}

gRPC サーバが起動していない場合はエラーメッセージが表示される。

Error! 14: No connection established