こんにちは、Watanabe Jin(@Sicut_study)です。
今回はGraphQLの初心者向けチュートリアルを行っていきます。
REST APIと肩を並べて知られるGraphQLですが、なかなかイメージがしづらく初心者には難しいものです。私も駆け出しの頃に勉強しましたが全然身につけることができませんでした。
- GraphQLのクエリのイメージがしづらい
- DBとGraphQLをどう接続すればいいかわからない
- 実際にアプリケーションと組み合わせる方法がわからない
このように教材をやりながら感じていました。
GraphQLはふわっとしてしまう分なるべく実践的に学びたいのですが、実践的な教材は少ないです。
これらの欠点を克服するために、チュートリアルでは以下の点を意識して作成しています。
- 初心者でもGraphQLをイメージできるように図解を多用してとことん解説しています
- アプリケーションに組み込めるようにReactで実際にアプリを作成して接続します
- GraphQLとPrismaを使って実際のデータベース接続をして本番でも利用できるようにしました
チュートリアルを最後までやることで、
GraphQLを理解できることはもちろんのこと、
GraphQLを利用して実践的なアプリを作れるスキルが身につきます。
チュートリアルはインプットであり、本当にスキルとして身につけるならアウトプットをしないといけません。アウトプットとして個人開発に導入できるようになるべく実践できるように解説していきます。
動画教材もご用意してます
こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材ではわからない細かい箇所があれば動画も活用ください。
対象者
- GraphQLという名前を聞いたことがある人
- GraphQLをやったことがあるが身につけられなかった人
- REST APIとの違いを知りたい人
- 手を動かしてスキルを身に着けたい人
- TypeScriptを使ってみたい人
- Reactをやったことがない人
- JavaScriptはなんとなく理解している人
1. REST VS GraphQL
GraphQLを実際に開発する前にREST APIとの違いを理解しておくことで、GraphQLをより理解しやすくなるので解説していきます。
まずはREST APIの特徴から紹介します。
REST APIでは/api/user
や/api/user/1
などリソースがURLで表現されています。
このような仕組みにするとURLが識別しやすかったり、HTTPメソッドでリソースを操作できるメリットはあるもののいくつかのデメリットがありました。
1つ目にあげられるのが、オーバーフェッチの問題です。
あなたがニュース表示をアプリに追加しようと考えたとします。社内にはニュースを返すようなAPIがあったとして、それを使えば解決できることがわかりました。
ニュースAPIを使ってリリースしたところ、各所から 「アプリが遅くなった」 という連絡がくるようになりました。
調査をしたところ、今回は利用していないニュース画像がかなり大きなデータ量になっていて、必要のない項目を取得するのに時間がかかっていたことが判明しました。
この場合、REST APIのエンドポイントを新たに追加しないといけないので工数がかかってしまいます。
もう一つのよくある問題点は、異なるエンドポイントを叩くときに大変というところです。マイクロサービスなどを採用するとよく起きる問題だと私も感じています。
たとえば、先程のニュースの情報にニュースの発行元の情報も一緒に表示しようとします。
するとこのように別APIを2つ叩いてJSONを1つにしてフロント側に返す必要があります(BFFの役割をしたAPIを作る場合もありこれもコストがかかります)
そんなREST APIのデメリットを解消したのがGraphQLです。
GraphQLはREST APIとは異なり、単一のエンドポイントしかありません。
このエンドポイントに対してクエリを投げることでほしいデータのみを取得することができます。先程のニュースを取得する例だと画像は必要ないので含めないこともできるのです。
そして複数のエンドポイントを叩かなくてもクエリによって、ニュースの情報と発行元の情報をまとめて取得することもできます。
ここまで聞くと魔法のように思えますが、いくつかのデメリットもあるので紹介します。
いくつかデメリットはあるのですが、特に学習コストがネックになることが多いです。普段使っているのはREST APIだと思うのでプロジェクトにメンバーが加わったときに学習コストが毎回かかってしまうというのはよくある話です。
これらのメリット、デメリットを考えながらプロジェクト似合いそうな手段を選択していただきたいです。
とはいえ、GraphQLを知っている/知っていないでは技術選択の可能性が大きく変わるのでぜひ一度学んでほしいです。このチュートリアルでは学習コストを大幅に下げられるようにわかりやすく実践で必要な箇所に絞って体系的に解説していきます。
2. GraphQLの環境構築をしよう
まずはGraphQLを気軽に試せるApollo Clientを0から構築して基本的なクエリを学んでいきます。
$ cd graphql-react
$ mkdir graphql-server
$ cd graphql-server
VSCodeで開いて設定していきます。
$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.
See `npm help init` for definitive documentation on these fields
and exactly what they do.
Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.
Press ^C at any time to quit.
package name: (graphql-react)
version: (1.0.0)
description:
entry point: (index.js)
test command:
git repository:
keywords:
author:
license: (ISC)
About to write to /home/jinwatanabe/workspace/qiit/graphql-react/package.json:
{
"name": "graphql-react",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"description": ""
}
Is this OK? (yes) yes
// Enterを押し進めて、最後にyesと入力
今回はTypeScriptで構築していくので必要なライブラリをインストールしていきます。
$ npm install -dev nodemon ts-loader ts-node typescript
次にTypeScriptのコンパイル設定を記述していきます。
$ touch tsconfig.json
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"lib": ["es2020"],
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"moduleResolution": "node",
"baseUrl": "src",
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"],
"compileOnSave": false
}
次にnodemonの設定ファイルを作ります。
$ touch nodemon.json
{
"watch": ["src"],
"ext": "*",
"exec": "ts-node ./src/server.ts"
}
教材では環境構築についてはあまり言及しないことが多いですが、今回はなぜここまでの環境構築を行っているのかも解説していきます。ここがイメージできると環境構築も簡単に行えるようになります。
今回はsrc
配下にあるserver.ts
を変更したら即座にサーバーに変更が反映されるようにこのような環境構築を行っています。そこで必要になるのがnodemonとts-nodeなのです。
図のように私達はこれからsrcにあるファイルを変更していきます。その都度サーバーを再起動するのは大変なのでnodemonが活躍します。またTypeSccriptはそのままではブラウザでは実行できないためts-nodeなどを利用してjsにコンパイルして実行するような仕組みになっています。
ここまででベースのTypeScriptの環境ができたので、GraphQLを使うためのApollo Serverを起動していきます。
$ npm install apollo-server graphql
次に実際にApollo Serverを起動するためのスクリプトを書いていきます。
$ mkdir src
$ touch src/server.ts
import { ApolloServer, gql } from "apollo-server";
const typeDefs = gql`
type Query {
message: String
}
`;
const resolvers = {
Query: {
message: () => "Hello World!",
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
});
server.listen().then(({ url }) => {
console.log(`Server ready at ${url}`);
});
次にPackage.jsonをすこし修正して起動コマンドを作ります。
scripts
を変更します。nodemonを使って変更を検知して、変更されたらTypeScriptをJavaScriptにコンパイルしてリロードし直しています。
"scripts": {
"ts-node": ".\\node_modules\\.bin\\ts-node",
"start": "NODE_ENV=development nodemon --config ./nodemon.json",
},
それではサーバーを起動しましょう。
$ npm run start
localhost:4000にアクセスすると以下の画面が表示されれば成功です。
ここでGit Commit
したくなる人もいるかと思いますが、node_module
がgit ingore
されていないので、設定してきます。
$ touch .gitingnore
node_modules
ここまでできたら終了です!好きなだけGitHubにPushしてください。
3. GraphQLのクエリについて
今回はTODOアプリを題材にGraphQLのクエリについて解説していきます。
まずは先程何気なくでてきた、typeDefs
とresolvers
についてです。
この2つはGraphQLを使う上でもっとも重要な要素となってきます。
TypeDefsはGraphQLのデータ構造を定義する構成図です。
「どんなデータがあり」「どんな関係性があるのか」を表したものです。
例えばTODOアプリであればこのようになります。
const typeDefs = gql`
type Todo {
id: ID!
title: String!
completed: Boolean!
}
`;
今回最後に完成するTODOアプリをここで紹介しておきます。
このアプリで必要なデータとして、
- それぞれのTODOを識別する
id
- それぞれのTODOのタイトル
title
- それぞれのTODOが完了したかを表すフラグ
completed
(true: 終わった、false: 終わっていない)
これらが今回のTODOアプリのデータ構造になります。
resolversは実際にデータを取得したり処理したりするための関数です。
TypeDefsで定義された型やフィールドごとにどのデータを返すかを指示します。resolversが実際の処理を行い結果を返します。
const resolvers = {
Query: {
getTodos: () => {
return [
{
id: "1",
title: "GraphQLを勉強する",
completed: false
},
{
id: "2",
title: "Reactを勉強する",
completed: false
}
]
}
}
};
ここでQuery
とgetTodos
というのがでてきました。
Queryはデータ取得のときに使う関数をまとめるものだと考えて大丈夫です。後にでてきますが、データを作成更新削除するためのMutationというのもあります。
getTodosはTODOをすべて取得するための具体的なリゾルバ関数です。GraphQLのクエリで指定することで関数を実行することが可能です。
それでは実際にTypeDefsとResoversを設定して、クエリを叩いてみましょう。
import { ApolloServer, gql } from "apollo-server";
const todos = [
{
id: "1",
title: "GraphQLを勉強する",
completed: false,
},
{
id: "2",
title: "Reactを勉強する",
completed: false,
},
];
const typeDefs = gql`
type Todo {
id: ID!
title: String!
completed: Boolean!
}
type Query {
getTodos: [Todo!]!
}
`;
const resolvers = {
Query: {
getTodos: () => todos,
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
});
server.listen().then(({ url }) => {
console.log(`Server ready at ${url}`);
});
ここで先程の説明にない箇所がでてきました。
const typeDefs = gql`
type Todo {
id: ID!
title: String!
completed: Boolean!
}
type Query {
getTodos: [Todo!]!
}
`;
TypeDefsではQueryの操作の部分(Resolversに書いたもの)も書く必要があります。ここが実際にクライアントがサーバーにリクエストできる入口となるのです。
[Todo!]!
とありますが、これは[Todo]でTodo型(id, title, completedをもつオブジェクト)の配列が返ってくることを表しており!
をつけることでnull
でないことを表しています。リストの各要素は必ずnullでなく[Todo!]
、リストそのものもnullではないと定義しています[Todo!]!
それではサーバーを起動して、localhost:4000を開きましょう。
チェックをいれてQuery your serverをクリックします。
エディタが開くので以下を入力します。
query GetTodos {
getTodos {
id
title
completed
}
}
そして実行ボタンを押すと右に先程のテストデータが表示されました。
query GetTodos
はクエリ名を表すものです。名前をつけるだけなので、GetTodosでなくHogeでも構いません。その次に実際に実行するResolversの関数を書きます。今回はgetTodos
を呼び出しました。
そして、ここからがREST APIとの違いで、必要なフィールドだけを書いていきます。
今回はすべてのフィールドを書きましたが、以下のようにするとidとtitleだけを取り出せます。
query GetTodos {
getTodos {
id
title
}
}
これで図解したときのニュースが重くなってしまう問題は解消しますね!
では、続いてTODO新規作成をクエリを使ってやっていきます。
追加、更新、削除はQueryではなく、Mutation
を使います。
REST APIのメソッドとの関係はこんな感じです。GET以外はMutationを使うと覚えておけばよいです。
import { ApolloServer, gql } from "apollo-server";
const todos = [
{
id: "1",
title: "GraphQLを勉強する",
completed: false,
},
{
id: "2",
title: "Reactを勉強する",
completed: false,
},
];
const typeDefs = gql`
type Todo {
id: ID!
title: String!
completed: Boolean!
}
type Query {
getTodos: [Todo!]!
}
type Mutation {
addTodo(title: String!): Todo!
}
`;
const resolvers = {
Query: {
getTodos: () => todos,
},
Mutation: {
addTodo: (_: unknown, { title }: { title: string }) => {
const newTodo = {
id: String(todos.length + 1),
title,
completed: false,
};
todos.push(newTodo);
return newTodo;
},
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
});
server.listen().then(({ url }) => {
console.log(`Server ready at ${url}`);
});
まずはresolversにMutationを追加してタイトルを受け取ってリストに追加する処理を書きました。
const resolvers = {
Query: {
getTodos: () => todos,
},
Mutation: {
addTodo: (_: unknown, { title }: { title: string }) => {
const newTodo = {
id: String(todos.length + 1),
title,
completed: false,
};
todos.push(newTodo);
return newTodo;
},
},
};
unkownになっている箇所は引数を使用しないことを示しています。anyなどにもできますが簡単に利用できていしまうため安全を考えて、明示的に確認をしないと使えないunkownにしています。
そしてresolversを書いたら、クライアントからの入口となるTypeDefs
にも定義を追加します。
const typeDefs = gql`
type Todo {
id: ID!
title: String!
completed: Boolean!
}
type Query {
getTodos: [Todo!]!
}
type Mutation {
addTodo(title: String!): Todo!
}
`;
では、実際に確認していくのでApolloクライアントを開いてください。
mutation AddTodo($title: String!) {
addTodo(title: "hoge") {
title
}
}
query Hoge {
getTodos {
id
title
completed
}
}
このように2つのクエリを実行することで、新しいTODOが追加できたことが確認できます。
mutation AddTodo($title: String!)
の$title
が気になった方もいるかもしれません
今回は直接hoge
という文字でTODOを作成しているので必要性が感じられませんが、本来GraphQLサーバーに対して変数を送ってクエリを実行します。
mutation AddTodo($title: String!) {
addTodo(title: $title) {
id
title
completed
}
}
{
"title": "hoge"
}
今回はクライアントを使って便利に実行しているので必要性は感じられませんが、このあとは$title
を利用するクエリを使っていきます。
残りが更新と削除ですが、ほとんど変わらないためコードを紹介して終わります。
詳しく知りたい方は動画の方をご確認ください。
import { ApolloServer, gql } from "apollo-server";
const todos = [
{
id: "1",
title: "GraphQLを勉強する",
completed: false,
},
{
id: "2",
title: "Reactを勉強する",
completed: false,
},
];
const typeDefs = gql`
type Todo {
id: ID!
title: String!
completed: Boolean!
}
type Query {
getTodos: [Todo!]!
}
type Mutation {
addTodo(title: String!): Todo!
updateTodo(id: ID!, completed: Boolean!): Todo!
deleteTodo(id: ID!): Todo!
}
`;
type AddTodo = {
title: string;
};
const resolvers = {
Query: {
getTodos: () => todos,
},
Mutation: {
addTodo: (_: unknown, { title }: AddTodo) => {
const newTodo = {
id: String(todos.length + 1),
title,
completed: false,
};
todos.push(newTodo);
return newTodo;
},
updateTodo: (
_: unknown,
{ id, completed }: { id: string; completed: boolean }
) => {
const todo = todos.find((todo) => todo.id === id);
if (!todo) {
throw new Error("Todo not found");
}
todo.completed = completed;
return todo;
},
deleteTodo: (_: unknown, { id }: { id: string }) => {
const index = todos.findIndex((todo) => todo.id === id);
if (index === -1) {
throw new Error("Todo not found");
}
const deletedTodo = todos.splice(index, 1);
return deletedTodo[0];
},
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
});
server.listen().then(({ url }) => {
console.log(`Server ready at ${url}`);
});
更新のクエリは以下を実行します。ここでサーバーが再起動するとTODOが初期の状態に戻る可能性もあるため、一応新規追加しておきます。
mutation AddTodo($title: String!) {
addTodo(title: "hoge") {
title
}
}
mutation Update($updateTodoId: ID!, $completed: Boolean!) {
updateTodo(id: "3", completed: true) {
id
title
completed
}
}
query Hoge {
getTodos {
id
title
completed
}
}
削除は以下のクエリで行います。タスクhogeを消します。
mutation AddTodo($title: String!) {
addTodo(title: "hoge") {
id
title
completed
}
}
mutation DeleteTodo($deleteTodoId: ID!) {
deleteTodo(id: "3") {
id
title
completed
}
}
query Hoge {
getTodos {
id
title
completed
}
}
ここまででGraphQLnの基本が学べたので次はデータの永続化をするためにデータベースを導入していきます。
4. データの永続化をする
ここからはPrismaとSQLiteでデータの永続化をしていきます。
先程作ったサーバーではデータの永続化ができておらずサーバーを再起動するとTODOの状態がリセットされて初期状態(2つのTODOがある)に戻ってしまいます。
そこで利用するのが、PrismaとSQliteです。
Prismaはデータベースとのやり取りを簡単にするツールです。
ORMとして使われます。ORMとはデータベースのテーブルをオブジェクトとして操作できる技術で、SQLを書かなくてもJavaScriptのコードだけでデータベース操作ができるようになります。
Prismaを使えばprisma.user.create
だけで新しいユーザーをDBに追加することができます。
SQLiteは軽量で組み込み型のリレーショナルデータベースです。
アプリの中でデータベースを持てるので外部にサーバーを立てる必要がありません。小規模なプロジェクトでよく使われます。
本来DBを用意するのであればAWSなどクラウドサービスを利用したり、Dockerでコンテナを立てたりコストがかかりますが、SQLiteはアプリケーション内に用意でき、ファイルベースとなっているので簡単に利用が可能です。
それでは実際にSQliteを利用してデータの永続化をしてみます。
まずはPrismaをインストールしましょう。
$ npm install prisma --save-dev
$ npx prisma init --datasource-provider sqlite
prismaというディレクトリができました。
中にあるschema.prisma
を修正します。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Todo {
id String @id @default(cuid())
title String
completed Boolean
createdAt DateTime @default(now())
}
url
をfile:./dev.db
としました。これによりDBの情報がschema.prismaと同じ位置にファイルとして生成されます。
model Todo {
id String @id @default(cuid())
title String
completed Boolean
createdAt DateTime @default(now())
}
は先程のTypeDefsの内容を入れています。新しくcreatedAtを追加しました。
@id @default(cuid())
とすることで自動でUUIDをIDに採番してくれます。
@default(now())
でデフォルトはテーブル作成の時間を設定してくれます。
それではマイグレートを行っていきます。マイグレートとは今設定した内容を実際のデータベースに反映させる作業です。
$ npx prisma migrate dev --name init
ここまで行うと色々設定ファイルが作成されます。
migratitonsの中にはマイグレートの履歴のようなものが残っています。
dev.dbは先程設定した項目でDBの情報を保持しているファイルになります。
これでPrismaとSQLiteの設定が終わりました。早速GraphQLとDBを接続していきましょう!
といっても実はそこまで難しくないです!
import { PrismaClient } from "@prisma/client";
import { ApolloServer, gql } from "apollo-server";
const prisma = new PrismaClient();
type Context = {
prisma: PrismaClient;
}
const typeDefs = gql`
type Todo {
id: ID!
title: String!
completed: Boolean!
}
type Query {
getTodos: [Todo!]!
}
type Mutation {
addTodo(title: String!): Todo!
updateTodo(id: ID!, completed: Boolean!): Todo!
deleteTodo(id: ID!): Todo!
}
`;
type AddTodo = {
title: string;
};
const resolvers = {
Query: {
getTodos: async (_: unknown, args: any, context: Context) => {
return await context.prisma.todo.findMany();
},
},
Mutation: {
addTodo: (_: unknown, { title }: AddTodo, context: Context) => {
return context.prisma.todo.create({
data: {
title,
completed: false,
},
});
},
updateTodo: (
_: unknown,
{ id, completed }: { id: string; completed: boolean },
context: Context
) => {
return context.prisma.todo.update({
where: { id },
data: { completed },
});
},
deleteTodo: (_: unknown, { id }: { id: string }, context: Context) => {
return context.prisma.todo.delete({
where: { id },
});
},
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
context: () => ({ prisma }),
});
server.listen().then(({ url }) => {
console.log(`Server ready at ${url}`);
});
コードがPrismaを利用することでスッキリしました!
まず修正したのはテストデータをなくしました。
// データベースを使うため不要
const todos = [
{
id: "1",
title: "GraphQLを勉強する",
completed: false,
},
{
id: "2",
title: "Reactを勉強する",
completed: false,
},
];
そしてPrisma Clientの初期設定を行います。
const prisma = new PrismaClient();
type Context = {
prisma: PrismaClient;
}
やっていることはPrismaクライアントを使う初期化(インスタンス生成)と、このあとresolversの中でcontext.prisma
を使うための型を宣言しています。
getTodos: async (_: unknown, args: any, context: Context) => {
return await context.prisma.todo.findMany();
},
ここでcontext: Context
と受け取り、context.prisma
と呼び出しています。
contextの型をはっきり書いてあげることでcontextにはprismaが呼び出せるということをTypeScriptにわからせてあげる必要があるために型宣言をしました。
const server = new ApolloServer({
typeDefs,
resolvers,
context: () => ({ prisma }),
});
次にApollo Serverにcontextという引数でresolversにprismaを渡せるように設定します。
getTodos: async (_: unknown, args: any, context: Context) => {
return await context.prisma.todo.findMany();
},
あとはgetTodosであれば、prismaの便利な関数であるfindMany
を使ってすべてのレコードを取得します。
ドキュメントをみると操作に対してどのようにprismaを呼べば良いのかが書いてあります!
それではデータが永続化できているかを確認します。
サーバーを起動し直してすべてのクエリを確かめます。
mutation AddTodo($title: String!) {
addTodo(title: "hoge") {
id
title
completed
}
}
query Hoge {
getTodos {
id
title
completed
}
}
データが追加できたことを確認できたら一度サーバーを再起動します。
$ npm run start
そしてデータを確認しましょう。
query Hoge {
getTodos {
id
title
completed
}
}
しっかり永続化がされていそうです!(私は2回追加したので2つになっています)
それでは他のクエリも正しく動くかを確認しましょう
mutation Update($updateTodoId: ID!, $completed: Boolean!) {
updateTodo(id: "あなたの追加したTODOのUUIDをいれる", completed: true) {
id
title
completed
}
}
mutation DeleteTodo($deleteTodoId: ID!) {
deleteTodo(id: "あなたの追加したTODOのUUIDをいれる") {
id
title
completed
}
}
注意点はIDが今回からPrismaのSchemaで設定した自動採番によるUUIDになっていることです。
テストデータでは連番で入れていましたが変わるのでご自身で設定してクエリを実行してください
model Todo {
id String @id @default(cuid()) ← これ
title String
completed Boolean
createdAt DateTime @default(now())
}
更新削除が確認できたらPrismaとGraphQLに関してはこれにて終わりです。
今回のチュートリアルはここでは終わりません。実際にサンプルアプリを構築して組み込みたいと思います。
あとでデータを使いたいので以下のクエリでテストデータを入れておきましょう
mutation AddTodo($title: String!) {
addTodo(title: "お昼ご飯をつくる") {
id
title
completed
}
}
5. Reactの環境構築
ここからはReactで簡単なTODOアプリを作成してGraphQLを叩いてデータを取得更新していきます。
Reactとかっこいい画面を簡単に作れるコンポーネントライブラリShadcnが実行できる環境を用意します。
Node.jsが実行できる環境がお手元にない方は以下を参考にそれぞれのOSにあった方法でインストールしてください!
インストールができたことを以下の確認で確認してください
❯ node -v
v18.17.0
Reactのプロジェクトを構築します。
今回はViteを利用していきます。Viteは次世代のビルドツールで早くて無駄のない環境を提供してくれます。
cd ..
最初に作成したディレクトリgraphql-react
へ移動してください。
❯ npm create vite
Need to install the following packages:
create-vite@5.5.2
Ok to proceed? (y) y
✔ Project name: … todo-client
✔ Select a framework: › React
✔ Select a variant: › TypeScript
$ cd todo-clientcd/
$ npm i
$ npm run dev
http://localhost:5173にアクセスして以下の画面が表示されればReact環境が無事できています。
次にShadcnを利用するために、Shadcnの内部で利用しているTailwindCSSを導入していきます。
$ npm install -D tailwindcss postcss autoprefixer
$ npx tailwindcss init -p
プロジェクトをVSCodeで開いてtailwind.config.js
を以下に変えます
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
src/index.css
を変更します
@tailwind base;
@tailwind components;
@tailwind utilities;
src/App.tsx
を変更してスタイルがあたるかをチェックします
function App() {
return (
<>
<div>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Button
</button>
</div>
</>
);
}
export default App;
一度サーバーを落としてnpm run dev
で起動したらボタンが表示されました
TailwindCSSが導入できたので、Shadcnを導入します。
tsconfig.json
を以下に修正します。
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
tsconfig.app.json
を修正します。
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src"]
}
次に以下を実行します。
$ npm i -D @types/node
そしてvite.config.ts
を修正します。
import path from "path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})
それでは準備ができたので実際にShadcnを初期化して入れていきます。
$ npx shadcn@latest init
Need to install the following packages:
shadcn@2.1.0
Ok to proceed? (y) y
✔ Preflight checks.
✔ Verifying framework. Found Vite.
✔ Validating Tailwind CSS.
✔ Validating import alias.
✔ Which style would you like to use? › New York
✔ Which color would you like to use as the base color? › Neutral
✔ Would you like to use CSS variables for theming? … no / yes
✔ Writing components.json.
✔ Checking registry.
✔ Updating tailwind.config.js
✔ Updating src/index.css
✔ Installing dependencies.
✔ Created 1 file:
- src/lib/utils.ts
Success! Project initialization completed.
You may now add components.
試しにボタンコンポーネントをダウンロードしてきて、使ってみます。
$ npx shadcn@latest add button
import { Button } from "./components/ui/button";
function App() {
return (
<>
<div>
<Button>Click me</Button>
</div>
</>
);
}
export default App;
画面を確認してみます。npm run dev
でサーバーを起動した状態で確認します。
環境構築がすべて終わりました。
TODOアプリを構築する
まずはApp.tsx
にGraphQLからデータを取得して表示するところまで実装していきます。
いきなりGraphQLと接続すると難しい方もいるかと思うのでテストデータを表示するところから始めます。
type Todo = {
id: string;
title: string;
completed: boolean;
};
function App() {
const todos = [
{ id: "1", title: "GraphQLを学ぶ", completed: false },
{ id: "2", title: "Reactを学ぶ", completed: true },
] as Todo[];
return (
<>
<div>
<h1>TO DO List</h1>
<input type="text" placeholder="TODOを追加してください" />
<button>追加</button>
<ul>
{todos.map((todo) => (
<li
key={todo.id}
style={{
textDecoration: todo.completed ? "line-through" : "none",
}}
>
<input type="checkbox" checked={todo.completed} />
{todo.title}
</li>
))}
</ul>
</div>
</>
);
}
export default App;
それではGraphQLを使って実際のデータベースのデータを取得しましょう。
ReactでApolloが使えるように初期設定をします。こちらはドキュメント通りにやります。
$ npm install @apollo/client graphql
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({
uri: "http://localhost:4000",
cache: new InMemoryCache(),
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<ApolloProvider client={client}>
<App />
</ApolloProvider>
);
接続するサーバーを初期設定しています。今回はローカルのサーバー(localhost:4000)です。
const client = new ApolloClient({
uri: "http://localhost:4000",
cache: new InMemoryCache(),
});
それでは実際にGraphQLでTODOの一覧を取得したコードを紹介していきます。
import { gql, useQuery } from "@apollo/client";
const GET_TODOS = gql`
query {
getTodos {
id
title
completed
}
}
`;
type Todo = {
id: string;
title: string;
completed: boolean;
};
function App() {
const { loading, data } = useQuery(GET_TODOS, {
fetchPolicy: "network-only",
});
const todos = data ? data.getTodos : [];
if (loading) return <p>Loading...</p>;
return (
<>
<div>
<h1>TO DO List</h1>
<input type="text" placeholder="TODOを追加してください" />
<button>追加</button>
<ul>
{todos.map((todo: Todo) => (
<li
key={todo.id}
style={{
textDecoration: todo.completed ? "line-through" : "none",
}}
>
<input type="checkbox" checked={todo.completed} />
{todo.title}
</li>
))}
</ul>
</div>
</>
);
}
export default App;
ここで先ほど作成したサーバーも起動しておきましょう
graphql-serverディレクトリで起動する
$ npm run start
ではコードを解説していきます。
const GET_TODOS = gql`
query {
getTodos {
id
title
completed
}
}
`;
先程叩いたクエリと同じものを用意しました。
const { loading, data } = useQuery(GET_TODOS, {
fetchPolicy: "network-only",
});
const todos = data ? data.getTodos : [];
if (loading) return <p>Loading...</p>;
@apollo/client
はuseQuery
という便利な関数(フック)を用意してくれます。
この関数にクエリを渡して実行するとデータが返ってきたタイミングで画面を再描画してくれます。
fechPolicy
はキャッシュをどうするかを選べるものです。
データが返ってこない最初のタイミングではdata.getTodos
が空となり、loadingという文字がでます。
データが取得できるとdata.getTodos
に値が入るのでTODOリストが表示されます。
{todos.map((todo: Todo) => (
<li
key={todo.id}
style={{
textDecoration: todo.completed ? "line-through" : "none",
}}
>
<input type="checkbox" checked={todo.completed} />
{todo.title}
</li>
))}
それでは追加と更新も同じ要領でやっていきましょう!
import { gql, useMutation, useQuery } from "@apollo/client";
import { useState } from "react";
const GET_TODOS = gql`
query {
getTodos {
id
title
completed
}
}
`;
const ADD_TODO = gql`
mutation addTodo($title: String!) {
addTodo(title: $title) {
id
title
completed
}
}
`;
const UPDATE_TODO = gql`
mutation updateTodo($id: ID!, $completed: Boolean!) {
updateTodo(id: $id, completed: $completed) {
id
title
completed
}
}
`;
type Todo = {
id: string;
title: string;
completed: boolean;
};
function App() {
const { loading, data } = useQuery(GET_TODOS, {
fetchPolicy: "network-only",
});
const todos = data ? data.getTodos : [];
const [addTodo] = useMutation(ADD_TODO);
const [updateTodo] = useMutation(UPDATE_TODO);
const [title, setTitle] = useState("");
if (loading) return <p>Loading...</p>;
const handleAddTodo = async () => {
await addTodo({
variables: { title },
refetchQueries: [{ query: GET_TODOS }],
});
setTitle("");
};
const handleUpdateTodo = async (id: string, completed: boolean) => {
await updateTodo({
variables: { id, completed: !completed },
refetchQueries: [{ query: GET_TODOS }],
});
};
return (
<>
<div>
<h1>TO DO List</h1>
<input
type="text"
placeholder="TODOを追加してください"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button onClick={handleAddTodo}>追加</button>
<ul>
{todos.map((todo: Todo) => (
<li
key={todo.id}
style={{
textDecoration: todo.completed ? "line-through" : "none",
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleUpdateTodo(todo.id, todo.completed)}
/>
{todo.title}
</li>
))}
</ul>
</div>
</>
);
}
export default App;
新しいTODOを追加してみました
TODOを更新してみました
画面をリロードしても同じデータであれば成功です。
それではコードを解説していきます。
const ADD_TODO = gql`
mutation addTodo($title: String!) {
addTodo(title: $title) {
id
title
completed
}
}
`;
const UPDATE_TODO = gql`
mutation updateTodo($id: ID!, $completed: Boolean!) {
updateTodo(id: $id, completed: $completed) {
id
title
completed
}
}
`;
先程GraphQLで確かめたクエリと同じものを用意しました。
const [addTodo] = useMutation(ADD_TODO);
const [updateTodo] = useMutation(UPDATE_TODO);
const [title, setTitle] = useState("");
こちらも同じくuseQuery
の代わりにuseMutation
を使っています。
titleは追加のときに入力されている値を使いたいので、useState
を使ってフォームの値を保存しています。
const handleAddTodo = async () => {
await addTodo({
variables: { title },
refetchQueries: [{ query: GET_TODOS }],
});
setTitle("");
};
const handleUpdateTodo = async (id: string, completed: boolean) => {
await updateTodo({
variables: { id, completed: !completed },
refetchQueries: [{ query: GET_TODOS }],
});
};
前とさほど変わりませんが、variablesに変数を送っています。
addTodoではステートに保存されているタイトルを入れており、updateTodoでは現在のcompletedを逆に(trueならfalse)して送っています。
そのあとrefechQueries
を実行しています。これはクエリが実行された後に自動で実行されるクエリでTODOの一覧を改めて取得して画面を更新しています。
<input
type="text"
placeholder="TODOを追加してください"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
titleは入力されたらuseStateのset
関数を使ってタイトルを更新しています。
onChangeは入力が変更されたら実行されます。
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleUpdateTodo(todo.id, todo.completed)}
/>
checkboxもonChangeを使っていますが、チェックされたらすぐにTODOのデータベースも更新するのでhandleUpdateTodo
を呼び出しています。呼び出されたTODOのIdとCompletedを関数に渡しています。
<button onClick={handleAddTodo}>追加</button>
追加ボタンにはonClick
を設定しました。これはクリックされたときにどんな処理をするのかを設定できます。ここではhandleAddTodo
を呼び出しています。
スタイルを整える
最後にかっこいいデザインにして終わりにしましょう。
ここではFramer Motionというアニメーションライブラリも利用していますので、気になる方は調べてみてください。
$ npm install framer-motion
Shadcn/uiのコンポーネントも追加しておきます。
$ npx shadcn@latest add checkbox
import { gql, useMutation, useQuery } from "@apollo/client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { CheckCircle2, PlusCircle } from "lucide-react";
import { Button } from "./components/ui/button";
import { Checkbox } from "./components/ui/checkbox";
const GET_TODOS = gql`
query {
getTodos {
id
title
completed
}
}
`;
const ADD_TODO = gql`
mutation addTodo($title: String!) {
addTodo(title: $title) {
id
title
completed
}
}
`;
const UPDATE_TODO = gql`
mutation updateTodo($id: ID!, $completed: Boolean!) {
updateTodo(id: $id, completed: $completed) {
id
title
completed
}
}
`;
type Todo = {
id: string;
title: string;
completed: boolean;
};
function App() {
const { loading, data } = useQuery(GET_TODOS, {
fetchPolicy: "network-only",
});
const todos = data ? data.getTodos : [];
const [addTodo] = useMutation(ADD_TODO);
const [updateTodo] = useMutation(UPDATE_TODO);
const [title, setTitle] = useState("");
if (loading) return <p>Loading...</p>;
const handleAddTodo = async () => {
await addTodo({
variables: { title },
refetchQueries: [{ query: GET_TODOS }],
});
setTitle("");
};
const handleUpdateTodo = async (id: string, completed: boolean) => {
await updateTodo({
variables: { id, completed: !completed },
refetchQueries: [{ query: GET_TODOS }],
});
};
return (
<>
<div className="min-h-screen bg-gradient-to-br from-teal-50 to-mint-100 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md bg-white rounded-xl shadow-2xl overflow-hidden"
>
<div className="bg-gradient-to-r from-teal-400 to-emerald-500 p-6">
<h1 className="text-3xl font-bold text-white mb-2">To-Do List</h1>
</div>
<div className="p-6">
<div className="flex mb-4">
<input
type="text"
placeholder="タスクを追加"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="flex-grow mr-2 bg-teal-50 border-teal-200 focus:ring-2 focus:ring-teal-300 focus:border-transparent"
/>
<Button
onClick={handleAddTodo}
className="bg-emerald-500 hover:bg-emerald-600 text-white"
>
<PlusCircle className="w-5 h-5" />
</Button>
</div>
<AnimatePresence>
{todos.map((todo: Todo) => (
<motion.div
key={todo.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -100 }}
transition={{ duration: 0.3 }}
className={`flex items-center mb-4 p-4 rounded-lg shadow-sm ${
todo.completed ? "bg-mint-100" : "bg-white"
}`}
>
<Checkbox
id={`todo-${todo.id}`}
checked={todo.completed}
onCheckedChange={() =>
handleUpdateTodo(todo.id, todo.completed)
}
className="mr-3 border-teal-400 text-teal-500"
/>
<label
htmlFor={`todo-${todo.id}`}
className={`flex-grow text-lg ${
todo.completed
? "line-through text-teal-600"
: "text-gray-800"
}`}
>
{todo.title}
</label>
{todo.completed && (
<CheckCircle2 className="w-5 h-5 text-teal-500 ml-2" />
)}
</motion.div>
))}
</AnimatePresence>
{todos.length === 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center text-teal-600 mt-6"
>
タスクがありません
</motion.div>
)}
</div>
</motion.div>
</div>
</>
);
}
export default App;
おわりに
いかがでしたでしょうか?
GraphQLはなかなか身につかないと私自身悩んでいましたが、実践的に学習することで利用するイメージがついたと思います。
テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください。
ここまで読んでいただけた方はいいねとストックよろしくお願いします。
@Sicut_study をフォローいただけるととてもうれしく思います。
また明日の記事でお会いしましょう!
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてくださ!
▼▼▼