- 記事一覧 >
- ブログ記事
Next.js, graphql-codegen, React Query, Apollo Server v4 で簡易BFF作成(2/2)
はじめに
Next.js v13、graphql-codegen 2.16.1、React v18、TypeScript、TanStack Query v4(React Query)、Apollo Server v4 で簡易 BFF を作成しました。
前回は、サーバーサイド、バックエンド部分(Next.js の pages/api/graphql
)を作成してきましたが、今回は、クライアントサイド、フロントエンド部分(pages/_app.tsx
、pages/index.tsx
)を作成していきます。
1.codegen 利用からサーバーサイドの実装、動作確認(前回の記事)
2.クライアントサイドの実装、動作確認(今回の記事)
になります。
【検証環境】
Ubuntu 20.04.2 LTS
node v14.20.0
npm 6.14.17/code>
@apollo/server 4.3.0
graphql 16.6.0
next 13.1.1
react 18.2.0
typescript 4.9.4
@graphql-codegen/cli 2.16.2
graphql-request 5.1.0
@tanstack/react-query 4.20.4
Windows 10 Pro x64
Visual Studio Code 1.74.2
各要素の意味については、前回の記事で説明していますので、省略します。
また、Next.js、React について基本的な事の説明はありません。いきなりソースコードを書き始めます。
graphql-codegen で自動生成した React Hooks を使います。TanStack Query、graphql-request 単独の使い方についての説明はこの記事にはありません。
キャッシュの生存時間等、オプションをいろいろ使えますが、オプションについてはほとんど触れていません。
今回の要件
名前を返す API(http://localhost:4000/names
)と住所を返す API(http://localhost:5000/addresses
)があるとします。
・フロントエンドから一つのエンドポイントに対してリクエスト
・一度のリクエストで、名前を返す API と住所を返す API の結果を一度に受け取る
・名前を返す API だけデータの追加、更新ができる
・名前を返す API だけから全データ取り出すことができる
・名前を返す API に id(何番目か)を指定して、指定の名前を1つだけ取り出すことができる
・住所を返す API だけから全データ取り出すことができる
・住所を返す API に id(何番目か)を指定して、指定の住所を1つだけ取り出すことができる
両 API を用いて実現できることについて、実用的な意味は無いです。今回の場合、BFF によって、Rest API 群(場合によっては、gRPC などを使ったマイクロサービス群など)を束ねるというのが趣旨です。
これに対して、GraphQL クエリーを実行するクライアントを作ります。
クライアントは、以下のことができるものとします。
・全ての名前と住所を返す GraphQL クエリーを実行 → レスポンスの JSON を画面に表示
・最初の名前だけを返す GraphQL クエリーを実行 → レスポンスの JSON を画面に表示
・名前を追加する GraphQL クエリーを実行(追加する名前は、ランダムとする。)→ 成功したら、全ての名前と住所を返す GraphQL クエリーを実行して、レスポンスの JSON を画面に表示
・最初の名前を更新する GraphQL クエリーを実行(更新する名前は、ランダムとする。)→ 成功したら、全ての名前と住所を返す GraphQL クエリーを実行して、レスポンスの JSON を画面に表示
前回は、Apollo Server の機能を使ってテストしましたが、自分で実装したクライアントで GraphSQL クエリを実行するということです。
React Hooks 自動生成
前回の記事と重複しますが、この前提が無いと意味不明になりますので、おさらいします。
graphql-codegen にて、GraphQL クエリーに合わせた React Hooks を生成します。
大半の説明は省略します。
詳細は、前回の記事を参照してください。
graphql-codegen init
graphql-codegen 初期化作業を行います。
$ npx graphql-codegen init
ここで、以下のように回答します。
What type of application are you building? Application built with React
Where is your schema?: (path or url) pages/api/graphql/schema.ts
Where are your operations and fragments?: graphql/**/*.graphql
Where to write the output: generated/resolvers.d.ts
Do you want to generate an introspection file? Yes
How to name the config file? codegen.yml
What script in package.json should run the codegen? codegen
codegen.ts
orcodegen.yml
が生成されて package.json に"codegen": "graphql-codegen --config codegen.yml"
が書き込まれます。
codegen.yml 設定
codegen.yml
が自動生成されていますので、これを編集します。
$ vi codegen.yml
# 既に生成物がある場合、上書き
overwrite: true
# スキーマファイルの場所
schema: pages/api/graphql/schema.ts
generates:
# resolvers(サーバー側)の TypeScript 型定義生成設定
./generated/resolvers.d.ts:
plugins:
- typescript
- typescript-resolvers
config:
useIndexSignature: true
contextType: ../../pages/api/graphql/resolvers#Context
./generated/graphql.ts:
documents: "graphql/**/*.graphql"
plugins:
- typescript
# @graphql-codegen/typescript-resolvers
# documents から TypeScript 型定義を生成するプラグイン
- typescript-operations
# documents から React Hooksコード生成するプラグイン
- typescript-react-query
config:
# React Hooks の fetcher に graphql-request を使用
# 指定しないと fetch が使われる。
fetcher: graphql-request
# Hooks を生成するか否か。true,falseにしても生成された。(?)何も変わらないので、コメントアウトしておく。
# isReactHook: true
# 生成物に useGetNameQuery.getKey = (variables: GetNameQueryVariables) => ['getName', variables];
# のように QueryKey を取り出すことができるメソッドが追加される。(['getName', {id: '0'}]のような値がキーとして返る。)
# react-query は QueryKey を使用してキャッシュを管理する。
# setQueryData や getQueryData などのメソッドには、このキーが必要。
# 今回直接react-queryを使っていないため、結局使わない。
exposeQueryKeys: true
# introspectionファイルを作成
./graphql.schema.json:
plugins:
- "introspection"
自動で package.json に追加された @graphql-codegen/client-preset
は不要なので、削除します。(末尾のカンマに注意。)
$ vi package.json
"devDependencies": {
"@graphql-codegen/cli": "2.16.2",
"@graphql-codegen/introspection": "2.2.3",
"@graphql-codegen/client-preset": "1.2.4"
}
↓
"devDependencies": {
"@graphql-codegen/cli": "2.16.2",
"@graphql-codegen/introspection": "2.2.3"
}
schema 作成
schema として指定した schema.ts を作成します。
注意:サーバーサイドに関係します。今回のクライアントサイドでは関係ありません。
$ mkdir pages/api/graphql
$ vi pages/api/graphql/schema.ts
import { gql } from "graphql-tag";
export const typeDefs = gql`
type Name {
name: String!
}
input NameInput {
name: String!
}
type Address {
address: String!
}
type Query {
names: [Name!]
name(id: ID!): Name
addresses: [Address!]
address(id: ID!): Address
}
type Mutation {
addName(input: NameInput!): Name!
updateName(id: ID!, input: NameInput!): Name!
}
`;
documents 作成
クライアント側の documents を作成します。
Query と Mutation があるため、2ファイルに分けます。(1ファイルに全部書いても、もっと細分化しても構いません。)
$ mkdir graphql
$ vi graphql/queries.graphql
$ vi graphql/mutations.graphql
query getNames {
names {
name
}
}
query getName($id: ID!) {
name(id: $id) {
name
}
}
query getAddresses {
addresses {
address
}
}
query getAddress($id: ID!) {
address(id: $id) {
address
}
}
query getNamesAndAddresses {
names {
name
}
addresses {
address
}
}
query getNameAndAddress($id: ID!) {
name(id: $id) {
name
}
address(id: $id) {
address
}
}
mutation addName($input: NameInput!) {
addName(input: $input) {
name
}
}
mutation updateName($id: ID!, $input: NameInput!) {
updateName(id: $id, input: $input) {
name
}
}
codegen 実行
generated/resolvers.d.ts
generated/graphql.ts
graphql.schema.json
を生成します。
Unable to find template plugin matching 'typescript-react-query'
のようにエラーになりますので、plugins に指定したパッケージを --save-dev
でインストールしてから、実行します。
$ npm install --save-dev @graphql-codegen/typescript-operations
$ npm install --save-dev @graphql-codegen/typescript-react-query
$ npm install --save-dev @graphql-codegen/typescript-resolvers
$ mkdir generated
$ npm install
$ npm run codegen
今回は、generated/graphql.ts
を使用します。ここに React Hooks が自動生成されています。
npm install
前回の記事で実行した npm install
をおさらいすると、以下です。
$ npm install --save-dev @graphql-codegen/cli
$ npm install graphql @apollo/server @apollo/datasource-rest graphql-tag
$ npm install --save-dev @graphql-codegen/typescript-operations
$ npm install --save-dev @graphql-codegen/typescript-react-query
$ npm install --save-dev @graphql-codegen/typescript-resolvers
今回、クライアント側に必要な npm を追加します。
$ npm install graphql-request @tanstack/react-query @tanstack/react-query-devtools
$ npm install react-toastify
$ npm install unique-names-generator
@tanstack/react-query / TanStack Query(React Query)
サーバー側のデータの取得と操作を容易にするために使用されるライブラリです。 React Query を使用すると、React アプリケーションでのサーバー状態の取得、キャッシュ、同期、および更新を簡単に行うことができます。
TanStack Query は、TS/JS、React、Solid、Vue で使えます。(Svelte も対応予定にあるようですが、2022 年 12 月現在まだ開発中のようです。)
@tanstack/react-query-devtools
React Query 専用の開発ツールです。React Query のすべての内部動作を視覚化するのに役立ち、ピンチに陥った場合にデバッグの時間を節約できる可能性があります。
作成するクライアントがあまりに殺風景のため、デフォルトでオンとします。使い方の説明はこの記事にはありません。
graphql-request
シンプルかつ軽量な GraphQL クライアントです。プロミス(非同期処理の最終的な完了もしくは失敗を表すオブジェクト)に対応しています。TypeScript をサポートしています。Node のサーバーサイド/ブラウザ側で使用できます。今回は、ブラウザ側で使用しています。
react-toastify
以下のようにトースト(スッと表れてスッと消える通知メッセージ)を実現するために導入します。
特に React Query とか、GraphQL とかには関係しません。
unique-names-generator
ランダムに適当な名前を返すツールです。追加したり更新したりする名前が毎回同じとかだとつまらないので、これを使ってスターウォーズのキャラクター名がランダムに生成されるようにしました。
_app.tsx 実装
$ vi pages/_app.tsx
説明は、ソースコード内のコメントを参照してください。
最初、以下のようになっていると思いますが、完全に書き換えます。
import '../styles/globals.css'
import type { AppProps } from 'next/app'
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ToastContainer } from "react-toastify";
// ToastContainerコンポーネント配置と同時にReactToastify.cssを読み込むreact-toastifyの作法
import "react-toastify/dist/ReactToastify.css";
const queryClient = new QueryClient();
export default function App({ Component, pageProps }: AppProps) {
return (
// 全体を通して、React Queryを有効にするため、(今回は、index.tsxだけだが)
// QueryClientProviderで要素を包みQueryClientをインスタンス化したqueryclientを設定。(必須)
<QueryClientProvider client={queryClient}>
{/* react-toastify の ToastContainerコンポーネントを配置 */}
{/* 注意:index.tsxに配置しても機能しない。親要素に配置が必要。 */}
<ToastContainer />
<Component {...pageProps} />
{/* react-query-devtoolsを初期表示から有効する。 */}
{/* falseの場合、最初ボタンだけ表示されて、クリックしたら開く。 */}
<ReactQueryDevtools initialIsOpen={true} />
</QueryClientProvider>
);
}
index.tsx 実装
$ vi pages/index.tsx
最初、以下のようになっていると思いますが、完全に書き換えます。
自動生成された
generated/graphql.ts
を使います。useQuery をそのまま使う場合と異なります。
import Head from 'next/head'
import Image from 'next/image'
import { Inter } from '@next/font/google'
import styles from '../styles/Home.module.css'
const inter = Inter({ subsets: ['latin'] })
export default function Home() {
return (
<>
<Head>
<title>Create Next App</title>
・・・
import Head from "next/head";
import { GraphQLClient } from "graphql-request";
// graphql-codegen を実行した生成物 graphql.ts から各フック関数を読み込み。
import {
useGetNamesAndAddressesQuery,
useGetNameQuery,
GetNamesAndAddressesQuery,
useAddNameMutation,
useUpdateNameMutation,
} from "../generated/graphql";
import { useState } from "react";
// 成功、エラーを表示するために、
// トースト(スッと表れてスッと消える通知メッセージ)を利用
import { toast } from "react-toastify";
// スターウォーズのキャラクタ名をランダムに返す
import { uniqueNamesGenerator, Config, starWars } from "unique-names-generator";
// (alias) new GraphQLClient(url: string, options?: PatchedRequestInit | undefined): GraphQLClient
// 今回は、オプション無しでシンプルにURLのみ指定。環境変数は使わないため、URL固定。
const graphQLClient = new GraphQLClient(
process.env.NEXT_PUBLIC_GRAPHQL_URL || "http://localhost:3000/api/graphql"
);
// unique-names-generator のスターウォーズのキャラクタ名を使う設定
const config: Config = {
dictionaries: [starWars],
};
export default function Home() {
// GetNamesAndAddressesQuery型(名前&住所が入る)
// data は、画面表示に使う。
const [data, setData] = useState<GetNamesAndAddressesQuery | undefined>(
undefined
);
// 全ての名前と住所を返す GraphQL クエリーを実行→レスポンスのJSONを画面に表示
const res1 = useGetNamesAndAddressesQuery(
graphQLClient,
{}, // GraphQLクエリの引数無し
{
// ユーザーがブラウザのコンポーネントにフォーカスを当てた時に自動でフェッチが動くのを抑止
refetchOnWindowFocus: false,
// ボタンをクリックしたタイミングで起動するため、ここでは実行されない。
enabled: false,
// 成功したときのコールバック
onSuccess(data) {
// 取得したデータ(JSON)を画面に反映
setData(data);
},
onError(error: any) {
// エラーの時、トーストに表示(注意:エラーは複数返る可能性がある)
error.response.errors.forEach((err: any) => {
toast(err.message, {
type: "error", // error タイプのデザイン(赤色)
position: "top-center", // 真ん中の上から出現
});
});
},
}
);
// 最初の名前だけを返す GraphQL クエリーを実行→レスポンスのJSONを画面に表示
const res2 = useGetNameQuery(
graphQLClient,
{ id: "0" }, // GraphQLクエリの引数に最初の名前と指定(ハードコード)
{
refetchOnWindowFocus: false,
enabled: false,
onSuccess(data) {
setData(data);
},
onError(error: any) {
error.response.errors.forEach((err: any) => {
toast(err.message, {
type: "error",
position: "top-center",
});
});
},
}
);
// 名前を追加する GraphQL クエリーを実行(追加する名前は、ランダムとする。)
// →成功したら、全ての名前と住所を返す GraphQL クエリーを実行して、レスポンスのJSONを画面に表示
// Mutation系(取得ではなく、更新系)は、ここでは実行されない。
const res3 = useAddNameMutation(graphQLClient, {
onSuccess() {
// "Name created successfully" とトースト表示
toast("Name created successfully", {
autoClose: 1000, // 1秒で消えるように設定
type: "success", // success タイプのデザイン(緑色)
position: "top-center", // 真ん中の上から出現
});
res1.refetch(); // 全ての名前と住所を返す GraphQL クエリーを実行
},
onError(error: any) {
error.response.errors.forEach((err: any) => {
toast(err.message, {
type: "error",
position: "top-center",
});
});
},
});
// 最初の名前を更新する GraphQL クエリーを実行(更新する名前は、ランダムとする。)
// →成功したら、全ての名前と住所を返す GraphQL クエリーを実行して、レスポンスのJSONを画面に表示
const res4 = useUpdateNameMutation(graphQLClient, {
onSuccess() {
toast("Name updated successfully", {
autoClose: 1000,
type: "success",
position: "top-center",
});
res1.refetch(); // 全ての名前と住所を返す GraphQL クエリーを実行
},
onError(error: any) {
error.response.errors.forEach((err: any) => {
toast(err.message, {
type: "error",
position: "top-center",
});
});
},
});
// GetNamesAndAddressesQueryボタンクリック
const onGetNamesAndAddressesQuery = async () => {
res1.refetch(); // 全ての名前と住所を返す GraphQL クエリーを実行
};
// GetNameQueryボタンクリック
const onGetNameQuery = async () => {
res2.refetch(); // 最初の名前だけを返す GraphQL クエリーを実行
// 注意:引数 { id: "0" } は、res2 作成時に指定している。
};
// AddNameMutationボタンクリック
const onAddNameMutation = async () => {
// スターウォーズのキャラクタ名をランダムに生成
const characterName: string = uniqueNamesGenerator(config);
res3.mutate({ input: { name: characterName } }); // 名前を追加する GraphQL クエリーを実行
// 注意:引数 { input: { name: characterName } } は、ここで指定。
};
// UpdateNameMutationボタンクリック
const onUpdateNameMutation = async () => {
const characterName: string = uniqueNamesGenerator(config); // 最初の名前を更新する GraphQL クエリーを実行
res4.mutate({ id: "0", input: { name: characterName } });
// 注意:引数 { id: "0", input: { name: characterName } } は、ここで指定。
};
return (
<>
<Head>
<title>Next App</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<button onClick={onGetNamesAndAddressesQuery}>
GetNamesAndAddressesQuery
</button>{" "}
<button onClick={onGetNameQuery}>GetNameQuery</button>{" "}
<button onClick={onAddNameMutation}>AddNameMutation</button>{" "}
<button onClick={onUpdateNameMutation}>UpdateNameMutation</button>
{/* GraphQLクエリ実行の結果JSONをスペース2個の文字列に整形して<pre></pre>タグで表示 */}
<pre>{JSON.stringify(data, null, 2)}</pre>
</>
);
}
完成しました!
動作確認
$ npm run dev
http://localhost:3000
にアクセスします。
GetNamesAndAddressesQuery ボタンをクリックして、全ての名前と住所を得ます。
ヨシ!
GetNameQuery ボタンをクリックして、最初の名前だけを得ます。
ヨシ!
AddNameMutation ボタンをクリックして、名前を追加します。
ヨシ!
UpdateNameMutation ボタンをクリックして、最初の名前を更新します。
ヨシッ!!
ちなみにですが、エラーになると、以下の挙動になります。
(データソースの REST API を落として、接続エラーの例)
注意:実際は、リトライするため、エラー表示までに時間がかかりました。
その他、宣伝、誹謗中傷等、当方が不適切と判断した書き込みは、理由の如何を問わず、投稿者に断りなく削除します。
書き込み内容について、一切の責任を負いません。
このコメント機能は、予告無く廃止する可能性があります。ご了承ください。
コメントの削除をご依頼の場合はTwitterのDM等でご連絡ください。