230
244

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【図解解説】これ1本でGraphQLをマスターできるチュートリアル【React/TypeScript/Prisma】

Last updated at Posted at 2024-12-01

graphql_beginner_full_course.png

こんにちは、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の特徴から紹介します。
 

image.png

REST APIでは/api/user/api/user/1などリソースがURLで表現されています。
このような仕組みにするとURLが識別しやすかったり、HTTPメソッドでリソースを操作できるメリットはあるもののいくつかのデメリットがありました。
 

image.png

1つ目にあげられるのが、オーバーフェッチの問題です。
あなたがニュース表示をアプリに追加しようと考えたとします。社内にはニュースを返すようなAPIがあったとして、それを使えば解決できることがわかりました。
 

image.png

ニュースAPIを使ってリリースしたところ、各所から 「アプリが遅くなった」 という連絡がくるようになりました。
 

image.png
 

調査をしたところ、今回は利用していないニュース画像がかなり大きなデータ量になっていて、必要のない項目を取得するのに時間がかかっていたことが判明しました。
この場合、REST APIのエンドポイントを新たに追加しないといけないので工数がかかってしまいます。

もう一つのよくある問題点は、異なるエンドポイントを叩くときに大変というところです。マイクロサービスなどを採用するとよく起きる問題だと私も感じています。
 

image.png

たとえば、先程のニュースの情報にニュースの発行元の情報も一緒に表示しようとします。
するとこのように別APIを2つ叩いてJSONを1つにしてフロント側に返す必要があります(BFFの役割をしたAPIを作る場合もありこれもコストがかかります)

 
image.png

そんなREST APIのデメリットを解消したのがGraphQLです。
 

image.png
 

GraphQLはREST APIとは異なり、単一のエンドポイントしかありません。
このエンドポイントに対してクエリを投げることでほしいデータのみを取得することができます。先程のニュースを取得する例だと画像は必要ないので含めないこともできるのです。

そして複数のエンドポイントを叩かなくてもクエリによって、ニュースの情報と発行元の情報をまとめて取得することもできます。

ここまで聞くと魔法のように思えますが、いくつかのデメリットもあるので紹介します。
 

image.png

いくつかデメリットはあるのですが、特に学習コストがネックになることが多いです。普段使っているのは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
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
nodemon.json
{
  "watch": ["src"],
  "ext": "*",
  "exec": "ts-node ./src/server.ts"
}

教材では環境構築についてはあまり言及しないことが多いですが、今回はなぜここまでの環境構築を行っているのかも解説していきます。ここがイメージできると環境構築も簡単に行えるようになります。
 

image.png

今回はsrc配下にあるserver.tsを変更したら即座にサーバーに変更が反映されるようにこのような環境構築を行っています。そこで必要になるのがnodemonts-nodeなのです。

図のように私達はこれからsrcにあるファイルを変更していきます。その都度サーバーを再起動するのは大変なのでnodemonが活躍します。またTypeSccriptはそのままではブラウザでは実行できないためts-nodeなどを利用してjsにコンパイルして実行するような仕組みになっています。
 

ここまででベースのTypeScriptの環境ができたので、GraphQLを使うためのApollo Serverを起動していきます。

$ npm install apollo-server graphql

次に実際にApollo Serverを起動するためのスクリプトを書いていきます。

$ mkdir src
$ touch src/server.ts
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にコンパイルしてリロードし直しています。

package.json
  "scripts": {
    "ts-node": ".\\node_modules\\.bin\\ts-node",
    "start": "NODE_ENV=development nodemon --config ./nodemon.json",
  },

それではサーバーを起動しましょう。

$ npm run start

localhost:4000にアクセスすると以下の画面が表示されれば成功です。

image.png

ここでGit Commitしたくなる人もいるかと思いますが、node_modulegit ingoreされていないので、設定してきます。

$ touch .gitingnore
.gitignore
node_modules

ここまでできたら終了です!好きなだけGitHubにPushしてください。

3. GraphQLのクエリについて

今回はTODOアプリを題材にGraphQLのクエリについて解説していきます。
まずは先程何気なくでてきた、typeDefsresolversについてです。
この2つはGraphQLを使う上でもっとも重要な要素となってきます。

TypeDefsはGraphQLのデータ構造を定義する構成図です。
「どんなデータがあり」「どんな関係性があるのか」を表したものです。

例えばTODOアプリであればこのようになります。

const typeDefs = gql`
  type Todo {
    id: ID!
    title: String!
    completed: Boolean!
  }
`;

今回最後に完成するTODOアプリをここで紹介しておきます。

image.png

このアプリで必要なデータとして、

  • それぞれの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
        }
      ]
    }
  }
};

ここでQuerygetTodosというのがでてきました。
Queryはデータ取得のときに使う関数をまとめるものだと考えて大丈夫です。後にでてきますが、データを作成更新削除するためのMutationというのもあります。

getTodosはTODOをすべて取得するための具体的なリゾルバ関数です。GraphQLのクエリで指定することで関数を実行することが可能です。

それでは実際にTypeDefsとResoversを設定して、クエリを叩いてみましょう。

src/server.ts
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を開きましょう。

image.png

チェックをいれてQuery your serverをクリックします。

エディタが開くので以下を入力します。

query GetTodos {
  getTodos {
    id
    title
    completed
  }
}

そして実行ボタンを押すと右に先程のテストデータが表示されました。

image.png

query GetTodosはクエリ名を表すものです。名前をつけるだけなので、GetTodosでなくHogeでも構いません。その次に実際に実行するResolversの関数を書きます。今回はgetTodosを呼び出しました。

そして、ここからがREST APIとの違いで、必要なフィールドだけを書いていきます。
今回はすべてのフィールドを書きましたが、以下のようにするとidとtitleだけを取り出せます。

query GetTodos {
  getTodos {
    id
    title
  }
}

image.png

これで図解したときのニュースが重くなってしまう問題は解消しますね!

では、続いてTODO新規作成をクエリを使ってやっていきます。
追加、更新、削除はQueryではなく、Mutationを使います。
 

image.png
 

REST APIのメソッドとの関係はこんな感じです。GET以外はMutationを使うと覚えておけばよいです。

src/server.ts
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
  }
}

image.png

このように2つのクエリを実行することで、新しいTODOが追加できたことが確認できます。

mutation AddTodo($title: String!)$titleが気になった方もいるかもしれません
今回は直接hogeという文字でTODOを作成しているので必要性が感じられませんが、本来GraphQLサーバーに対して変数を送ってクエリを実行します。

mutation AddTodo($title: String!) {
  addTodo(title: $title) {
    id
    title
    completed
  }
}
{
  "title": "hoge"
}

今回はクライアントを使って便利に実行しているので必要性は感じられませんが、このあとは$titleを利用するクエリを使っていきます。
 

残りが更新と削除ですが、ほとんど変わらないためコードを紹介して終わります。
詳しく知りたい方は動画の方をご確認ください。

src/server.ts
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
  }
}

image.png

削除は以下のクエリで行います。タスク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
  }
}

image.png

ここまででGraphQLnの基本が学べたので次はデータの永続化をするためにデータベースを導入していきます。

4. データの永続化をする

ここからはPrismaSQLiteでデータの永続化をしていきます。
先程作ったサーバーではデータの永続化ができておらずサーバーを再起動するとTODOの状態がリセットされて初期状態(2つのTODOがある)に戻ってしまいます。

そこで利用するのが、PrismaとSQliteです。

Prismaはデータベースとのやり取りを簡単にするツールです。
ORMとして使われます。ORMとはデータベースのテーブルをオブジェクトとして操作できる技術で、SQLを書かなくてもJavaScriptのコードだけでデータベース操作ができるようになります。
 

image.png

Prismaを使えばprisma.user.createだけで新しいユーザーをDBに追加することができます。
 

SQLiteは軽量で組み込み型のリレーショナルデータベースです。
アプリの中でデータベースを持てるので外部にサーバーを立てる必要がありません。小規模なプロジェクトでよく使われます。
 

image.png

本来DBを用意するのであればAWSなどクラウドサービスを利用したり、Dockerでコンテナを立てたりコストがかかりますが、SQLiteはアプリケーション内に用意でき、ファイルベースとなっているので簡単に利用が可能です。

それでは実際にSQliteを利用してデータの永続化をしてみます。
まずはPrismaをインストールしましょう。

$ npm install prisma --save-dev
$ npx prisma init --datasource-provider sqlite

image.png

prismaというディレクトリができました。
中にあるschema.prismaを修正します。

src/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())
}

urlfile:./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

ここまで行うと色々設定ファイルが作成されます。

image.png

migratitonsの中にはマイグレートの履歴のようなものが残っています。
dev.dbは先程設定した項目でDBの情報を保持しているファイルになります。

これでPrismaとSQLiteの設定が終わりました。早速GraphQLとDBを接続していきましょう!
といっても実はそこまで難しくないです!

src/server.ts
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
  }
}

image.png

データが追加できたことを確認できたら一度サーバーを再起動します。

$ npm run start

そしてデータを確認しましょう。

query Hoge {
  getTodos {
    id
    title
    completed
  }
}

image.png

しっかり永続化がされていそうです!(私は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になっていることです。
テストデータでは連番で入れていましたが変わるのでご自身で設定してクエリを実行してください

src/prisma/schema.prisma
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環境が無事できています。

image.png

次にShadcnを利用するために、Shadcnの内部で利用しているTailwindCSSを導入していきます。

$ npm install -D tailwindcss postcss autoprefixer
$ npx tailwindcss init -p

プロジェクトをVSCodeで開いてtailwind.config.jsを以下に変えます

tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

src/index.cssを変更します

src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

src/App.tsxを変更してスタイルがあたるかをチェックします

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で起動したらボタンが表示されました

image.png

TailwindCSSが導入できたので、Shadcnを導入します。

tsconfig.jsonを以下に修正します。

tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ],
    "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

tsconfig.app.jsonを修正します。

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を修正します。

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
App.tsx
import { Button } from "./components/ui/button";

function App() {
  return (
    <>
      <div>
        <Button>Click me</Button>
      </div>
    </>
  );
}

export default App;

画面を確認してみます。npm run devでサーバーを起動した状態で確認します。

image.png

環境構築がすべて終わりました。

TODOアプリを構築する

まずはApp.tsxにGraphQLからデータを取得して表示するところまで実装していきます。
いきなりGraphQLと接続すると難しい方もいるかと思うのでテストデータを表示するところから始めます。

src/App.tsx
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;

image.png

それではGraphQLを使って実際のデータベースのデータを取得しましょう。
ReactでApolloが使えるように初期設定をします。こちらはドキュメント通りにやります。

$ npm install @apollo/client graphql
src/main.tsx
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の一覧を取得したコードを紹介していきます。

src/App.tsx
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

image.png

ではコードを解説していきます。

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/clientuseQueryという便利な関数(フック)を用意してくれます。
この関数にクエリを渡して実行するとデータが返ってきたタイミングで画面を再描画してくれます。
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>
          ))}

それでは追加更新も同じ要領でやっていきましょう!

src/App.tsx
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を追加してみました

image.png

TODOを更新してみました

image.png

画面をリロードしても同じデータであれば成功です。
それではコードを解説していきます。

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
/src/App.tsx
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;

バズグルメ誕生 (12).gif

おわりに

いかがでしたでしょうか?
GraphQLはなかなか身につかないと私自身悩んでいましたが、実践的に学習することで利用するイメージがついたと思います。

テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください。

ここまで読んでいただけた方はいいねとストックよろしくお願いします。
@Sicut_study をフォローいただけるととてもうれしく思います。

また明日の記事でお会いしましょう!

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてくださ!
▼▼▼

230
244
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
230
244

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?