GraphQL レッスン5
GraphQL総合演習
Apollo Server + Apollo Client でフルスタックGraphQLアプリを構築しよう
プロジェクト概要:ブックレビューアプリ
この演習では、ブックレビューアプリを GraphQLで構築します。書籍の一覧表示、詳細表示、レビューの投稿機能を実装し、 これまで学んだスキーマ設計、リゾルバ実装、Apollo Clientの知識を総合的に活用します。
バックエンド
- • Apollo Server
- • スキーマ設計(Book, Review, User型)
- • リゾルバ実装
- • DataLoaderによるN+1対策
フロントエンド
- • React + Apollo Client
- • 書籍一覧・詳細画面
- • レビュー投稿フォーム
- • Optimistic Updates
Step 1: スキーマ設計
まずはアプリケーション全体のスキーマを設計します。 書籍、レビュー、ユーザーの3つの主要な型と、Query/Mutationを定義しましょう。
// schema.ts
export const typeDefs = `
type Book {
id: ID!
title: String!
author: String!
description: String!
coverImage: String
publishedYear: Int!
genre: Genre!
averageRating: Float
reviews: [Review!]!
reviewCount: Int!
}
type Review {
id: ID!
rating: Int!
comment: String!
book: Book!
user: User!
createdAt: String!
}
type User {
id: ID!
name: String!
avatar: String
reviews: [Review!]!
}
enum Genre {
FICTION
NON_FICTION
TECHNOLOGY
SCIENCE
BUSINESS
OTHER
}
input CreateReviewInput {
bookId: ID!
rating: Int!
comment: String!
}
input BookFilterInput {
genre: Genre
minRating: Float
keyword: String
}
type Query {
books(filter: BookFilterInput): [Book!]!
book(id: ID!): Book
me: User
}
type Mutation {
createReview(input: CreateReviewInput!): Review!
deleteReview(id: ID!): Boolean!
}
`;Step 2: リゾルバ実装
スキーマに対応するリゾルバを実装します。DataLoaderを使ってN+1問題を防ぎましょう。
// data.ts(サンプルデータ)
export const books = [
{
id: "1",
title: "GraphQL実践入門",
author: "田中太郎",
description: "GraphQLの基礎から応用まで学べる一冊",
coverImage: "/images/graphql-book.png",
publishedYear: 2024,
genre: "TECHNOLOGY",
},
{
id: "2",
title: "React設計パターン",
author: "佐藤花子",
description: "Reactアプリの設計手法を体系的に解説",
coverImage: "/images/react-book.png",
publishedYear: 2023,
genre: "TECHNOLOGY",
},
];
export const reviews = [
{ id: "1", bookId: "1", userId: "1", rating: 5, comment: "非常に分かりやすい!", createdAt: "2024-01-15" },
{ id: "2", bookId: "1", userId: "2", rating: 4, comment: "実践的な内容で良い", createdAt: "2024-02-01" },
{ id: "3", bookId: "2", userId: "1", rating: 4, comment: "設計の考え方が身につく", createdAt: "2024-03-10" },
];
export const users = [
{ id: "1", name: "山田次郎", avatar: "/avatars/jiro.png" },
{ id: "2", name: "鈴木三郎", avatar: "/avatars/saburo.png" },
];
// resolvers.ts
import DataLoader from "dataloader";
import { books, reviews, users } from "./data";
// DataLoader の作成
const createReviewsByBookLoader = () =>
new DataLoader<string, typeof reviews>(async (bookIds) => {
return bookIds.map((bookId) =>
reviews.filter((r) => r.bookId === bookId)
);
});
const createUserLoader = () =>
new DataLoader<string, (typeof users)[0] | null>(async (userIds) => {
return userIds.map((id) => users.find((u) => u.id === id) || null);
});
export const resolvers = {
Query: {
books: (_, { filter }) => {
let result = [...books];
if (filter?.genre) {
result = result.filter((b) => b.genre === filter.genre);
}
if (filter?.keyword) {
const kw = filter.keyword.toLowerCase();
result = result.filter(
(b) =>
b.title.toLowerCase().includes(kw) ||
b.author.toLowerCase().includes(kw)
);
}
return result;
},
book: (_, { id }) => books.find((b) => b.id === id),
me: (_, __, context) => {
if (!context.user) return null;
return users.find((u) => u.id === context.user.id);
},
},
Mutation: {
createReview: (_, { input }, context) => {
if (!context.user) throw new Error("認証が必要です");
const newReview = {
id: String(reviews.length + 1),
bookId: input.bookId,
userId: context.user.id,
rating: input.rating,
comment: input.comment,
createdAt: new Date().toISOString(),
};
reviews.push(newReview);
return newReview;
},
deleteReview: (_, { id }, context) => {
if (!context.user) throw new Error("認証が必要です");
const index = reviews.findIndex((r) => r.id === id);
if (index === -1) return false;
reviews.splice(index, 1);
return true;
},
},
Book: {
reviews: (book, _, { loaders }) => {
return loaders.reviewsByBookLoader.load(book.id);
},
reviewCount: async (book, _, { loaders }) => {
const bookReviews = await loaders.reviewsByBookLoader.load(book.id);
return bookReviews.length;
},
averageRating: async (book, _, { loaders }) => {
const bookReviews = await loaders.reviewsByBookLoader.load(book.id);
if (bookReviews.length === 0) return null;
const sum = bookReviews.reduce((acc, r) => acc + r.rating, 0);
return Math.round((sum / bookReviews.length) * 10) / 10;
},
},
Review: {
book: (review) => books.find((b) => b.id === review.bookId),
user: (review, _, { loaders }) => {
return loaders.userLoader.load(review.userId);
},
},
User: {
reviews: (user) => reviews.filter((r) => r.userId === user.id),
},
};Step 3: サーバー起動
スキーマとリゾルバを組み合わせてApollo Serverを起動します。
// server.ts
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { typeDefs } from "./schema";
import { resolvers } from "./resolvers";
import DataLoader from "dataloader";
import { reviews, users } from "./data";
const server = new ApolloServer({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
// 簡易的な認証(実際はJWT検証など)
const userId = req.headers["x-user-id"] as string;
const user = userId ? { id: userId } : null;
return {
user,
loaders: {
reviewsByBookLoader: new DataLoader(async (bookIds: readonly string[]) => {
return bookIds.map((bookId) =>
reviews.filter((r) => r.bookId === bookId)
);
}),
userLoader: new DataLoader(async (userIds: readonly string[]) => {
return userIds.map((id) =>
users.find((u) => u.id === id) || null
);
}),
},
};
},
listen: { port: 4000 },
});
console.log(`Server ready at ${url}`);Step 4: React フロントエンド
Apollo Clientを使って、書籍一覧の表示とレビュー投稿機能を実装します。
// graphql/queries.ts
import { gql } from "@apollo/client";
export const GET_BOOKS = gql`
query GetBooks($filter: BookFilterInput) {
books(filter: $filter) {
id
title
author
coverImage
genre
averageRating
reviewCount
}
}
`;
export const GET_BOOK = gql`
query GetBook($id: ID!) {
book(id: $id) {
id
title
author
description
publishedYear
genre
averageRating
reviews {
id
rating
comment
user {
name
avatar
}
createdAt
}
}
}
`;
export const CREATE_REVIEW = gql`
mutation CreateReview($input: CreateReviewInput!) {
createReview(input: $input) {
id
rating
comment
user {
name
avatar
}
createdAt
}
}
`;
// components/BookList.tsx
import { useQuery } from "@apollo/client";
import { GET_BOOKS } from "../graphql/queries";
function BookList() {
const [genre, setGenre] = useState("");
const { data, loading, error } = useQuery(GET_BOOKS, {
variables: {
filter: genre ? { genre } : null,
},
});
if (loading) return <p>読み込み中...</p>;
if (error) return <p>エラー: {error.message}</p>;
return (
<div>
<h1>ブックレビュー</h1>
{/* ジャンルフィルタ */}
<select value={genre} onChange={(e) => setGenre(e.target.value)}>
<option value="">すべて</option>
<option value="TECHNOLOGY">テクノロジー</option>
<option value="FICTION">フィクション</option>
<option value="BUSINESS">ビジネス</option>
</select>
{/* 書籍一覧 */}
<div className="grid grid-cols-3 gap-4">
{data.books.map((book) => (
<div key={book.id} className="card">
<img src={book.coverImage} alt={book.title} />
<h3>{book.title}</h3>
<p>{book.author}</p>
<p>評価: {book.averageRating ?? "---"}</p>
<p>レビュー: {book.reviewCount}件</p>
<a href={`/books/${book.id}`}>詳細を見る</a>
</div>
))}
</div>
</div>
);
}
// components/ReviewForm.tsx
import { useMutation } from "@apollo/client";
import { CREATE_REVIEW, GET_BOOK } from "../graphql/queries";
function ReviewForm({ bookId }: { bookId: string }) {
const [rating, setRating] = useState(5);
const [comment, setComment] = useState("");
const [createReview, { loading }] = useMutation(CREATE_REVIEW, {
// Optimistic Update
optimisticResponse: {
createReview: {
__typename: "Review",
id: "temp-" + Date.now(),
rating,
comment,
user: { __typename: "User", name: "あなた", avatar: null },
createdAt: new Date().toISOString(),
},
},
// キャッシュ更新:新しいレビューを書籍詳細に追加
update(cache, { data: { createReview: newReview } }) {
const existing = cache.readQuery({
query: GET_BOOK,
variables: { id: bookId },
});
if (existing) {
cache.writeQuery({
query: GET_BOOK,
variables: { id: bookId },
data: {
book: {
...existing.book,
reviews: [...existing.book.reviews, newReview],
},
},
});
}
},
onCompleted() {
setComment("");
setRating(5);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createReview({
variables: {
input: { bookId, rating, comment },
},
});
};
return (
<form onSubmit={handleSubmit}>
<h3>レビューを投稿</h3>
<label>
評価:
<select value={rating} onChange={(e) => setRating(Number(e.target.value))}>
{[5, 4, 3, 2, 1].map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="レビューを書いてください"
required
/>
<button type="submit" disabled={loading}>
{loading ? "投稿中..." : "レビューを投稿"}
</button>
</form>
);
}発展課題
基本実装ができたら、以下の機能追加に挑戦してみましょう。
- ページネーション — Cursor-basedページネーションを実装して、大量の書籍を効率的に取得
- 検索機能 — タイトル・著者名でのキーワード検索とジャンルフィルタの組み合わせ
- Subscription — 新しいレビューが投稿されたらリアルタイムで画面に反映
- 認証連携 — JWTトークンを使った認証とContext経由のユーザー情報取得
- エラーバウンダリ — GraphQLエラーを適切にキャッチしてユーザーフレンドリーなエラー画面を表示
まとめ
- スキーマ設計でアプリケーションのデータ構造とAPIを明確に定義
- リゾルバでデータの取得ロジックを実装し、DataLoaderでN+1問題を防止
- Apollo Clientの
useQueryとuseMutationでReactからGraphQL APIを操作 - Optimistic Updatesでレスポンシブな操作体験を実現
- キャッシュ管理を適切に行い、UIの整合性を維持