6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ReactとGraphQLを使って作るTODOアプリ

Last updated at Posted at 2022-12-23

はじめに

こんにちは,ペンギン丸です.学生兼フロントエンドエンジニアをしています.
本記事は私が来年からお世話になるDMM様の23卒アドベントカレンダー24日目です.

何か書けるような技術無いかな〜っと考えたところ何も思い浮かばなかった為,
逆に全然分かってない技術(GraphQL)を使ってTODOアプリを作ることにしました.

最近はお絵かきにかまけて技術の勉強をしてなかったので,手を動かして行きます!

本記事の目標

「GraphQL? 何それ美味しいの?」って方でも
この記事通りに作業すればGraphQLを用いてTODOアプリが作れてしまい,何となくGraphQLが分かった気になれることを目標にします.

GraphQLそのものに関する説明や,細かな用語の説明は他の方の記事に任せ,なるべく少ないコードで必要最低限のTODOアプリが作れたら良いなと思います.
張り切って行きましょう!

前提条件

Node.jsとnpmは既にインストールされている前提で作業します.
また,本記事の大部分はApolloの公式チュートリアルを参考にしています.

サーバー側作業

まずは主役のGraphQLを用いたサーバー側を作ります.
最低限のシンプルな実装にしたかった為,コードはindex.jsファイルに100行ちょっとのみです.

インストール

全体の作業用ディレクトリと,その中にserver用のディレクトリも作り必要なものをインストールします.

mkdir GraphQL_React_TODO
cd GraphQL_React_TODO
mkdir server
cd server
npm init -y
npm install graphql
npm install apollo-server

GraphQLと,Node.jsでGraphQLサーバーを実装するためのライブラリであるapollo-server
をインストールしています.
また,今回はSQLiteとSequelizeを使用してDBとのやりとりをする為,その2つもインストールします.

npm install sqlite3
npm install sequelize

最初にrequireするものたち

server/index.js
const { ApolloServer } = require("apollo-server");
const { gql } = require("apollo-server");
const { DataSource } = require("apollo-datasource");
const { Sequelize, DataTypes } = require("sequelize");

さっきインストールした方々を呼び出してます.

スキーマの定義

スキーマとは「構造」とかそういった意味で使われる言葉のようで,Apolloの公式のチュートリアルには

schema は設計図のようなもので graph API で使用される全てのデータの型とそれらの関係性を示しています。また schema は query を用いて取得できるデータがどのようなものか、それから mutation を用いて更新できるデータがどのようなものか、ということも定義しています。

と書かれています.
フロントエンジニアとしてGraphQLに携わる際にはこのスキーマ定義をよく確認します.
今の自分はざっくりと以下のように認識しています.

  • Query → 値の取得.どうやって何が取得できるか分かる
  • Mutation → 値の変更.何を渡す必要があり,何が返ってくるのか分かる
  • その他 → QueryとかMutationで使う型の定義

上記を踏まえTODOアプリのスキーマを定義したものがこちらです.

server/index.js
// スキーマの定義 ----------
const typeDefs = gql`
  type Query {
    todoList: [todo]
  }
  type Mutation {
    createTodo(text: String): todo
    deleteTodo(id: ID!): Boolean
    updateTodo(id: ID!, text: String): Boolean
  }
  type todo {
    id: ID!
    text: String
  }
`;

TODOリストを取得するためのQueryが1つ,TODOの作成,削除,更新を行うMutationがそれぞれ1つずつ,最後に1つのTODOの型を決めて終わりです.

このスキーマで最低限のCRUDを行います.
ちなみに!はnullを許さないことを示しています.

データの繋ぎ込み

まずORマッパーのSequelizeを用いてSQLiteのデータベースを初期化し,todoテーブルを作成します.todoテーブルには最低限のidとtextのカラムだけ用意しました.

server/index.js
// DBの初期化--------------
const createStore = () => {
  const db = new Sequelize({
    dialect: "sqlite",
    storage: "./store.sqlite",
  });
  const todoList = db.define("todo", {
    id: {
      type: DataTypes.INTEGER,
      primaryKey: true,
      autoIncrement: true,
    },
    text: DataTypes.TEXT,
  });
  return { db, todoList };
};
const store = createStore();
// tableの作成
store.todoList.sync();

次に,どのようにDBを操作するか定義し,データと繋ぎ込んでいきます.
ここでは主にSequelizeの書き方の慣れが求められたので,公式ドキュメントを確認してから作業するとスムーズに作業できると思います.

server/index.js
// データとの繋ぎ込み -------
class TodoListAPI extends DataSource {
  constructor({ store }) {
    super();
    this.store = store;
  }
  initialize(config) {
    this.context = config.context;
  }
  async createTodo({ text: text }) {
    const todo = await this.store.todoList.findOrCreate({ where: { text } });
    return todo[0];
  }
  async updateTodo({ id: id, text: text }) {
    const updateTodo = await this.store.todoList.update(
      { text: text },
      { where: { id: id } }
    );
    return !!updateTodo;
  }
  async deleteTodo({ id: id }) {
    const todo = await this.store.todoList.destroy({
      where: { id },
    });
    return !!todo;
  }
  async getTodoList() {
    return await this.store.todoList.findAll();
  }
}

DB周りやバックエンドにあまり慣れていない為,結構詰まりました...

リゾルバの定義

ここではスキーマに定義したQuery,Mutationと対応したResolverを定義し,スキーマのQuery,Mutationが実行された際に対応する処理を記述します.具体的な処理は先ほどのデータとの繋ぎ込みで記述しているため,ここでは関数を呼び出して対応づけをしています.

index.js
// リゾルバの定義 ----------
const resolvers = {
  Query: {
    todoList: async (_, __, { dataSources }) =>
      dataSources.todoListAPI.getTodoList(),
  },
  Mutation: {
    createTodo: async (_, { text }, { dataSources }) => {
      return await dataSources.todoListAPI.createTodo({ text });
    },
    deleteTodo: async (_, { id }, { dataSources }) => {
      return !!(await dataSources.todoListAPI.deleteTodo({ id }));
    },
    updateTodo: async (_, { id, text }, { dataSources }) => {
      return !!(await dataSources.todoListAPI.updateTodo({ id, text }));
    },
  },
};

インスタンスを作成しサーバーを走らせる

ここまでで準備は終わっており,下記のようにApolloServerのインスタンスを作成してサーバーを起動できます.

index.js
// ApolloServerのインスタンス作成
const server = new ApolloServer({
  typeDefs,
  resolvers,
  dataSources: () => ({
    todoListAPI: new TodoListAPI({ store }),
  }),
});
// サーバーを走らせる
server.listen().then(({ url }) => {
  console.log(`立ち上がったよ!${url}`);
});

後は下記のコマンドを叩けばサーバーが立ち上がりhttp://localhost:4000/にアクセスできます!

npm index.js

動作確認

以下のサイトへアクセスすることで,先ほど定義したスキーマを確認したり実際にクエリを発行することができます.

以下,試しに適当なTODOを作成した画面です.
スキーマ定義とリゾルバ定義で登場したcreateTodoを用いて新しくTODOアプリを作成しています.画面ではid,text共にResponseとして取得していますが,どちらか一方のみ取得するなども可能です.

スクリーンショット 2022-12-22 23.26.32.png

作成したTODOリストをtodoListクエリを用いて取得した画面です.
このように簡単に動作確認できるのは嬉しいですね!
スクリーンショット 2022-12-22 23.28.54.png

クライアント側作業

クライアント側はサクッと行きます.
serverディレクトリと同じ階層の場所で以下のコマンドを実行し,Reactアプリの雛形を作成します.

npx create-react-app client

その後,作成されたclientディレクトリへ移動し必要なものをインストールします.

cd clinet
npm install apollo/client  
npm install graphql

色々なファイルが作成されますが,今回編集するのはindex.jsApp.jsのみです.
index.jsは以下の通りです.
主な変更点はApolloClientのインスタンスを作成してしているところです.
ApolloClientには色々と便利なキャッシュ機能があるようなのですが,今回は使用していません.またの機会にはキャッシュを活用したアプリも動かしてみたいですね.

clinet/index.js
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";

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

const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Failed to find the root element");
const root = ReactDOM.createRoot(rootElement);

root.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>
);
reportWebVitals();

本体のApp.jsは以下の通りです.
長いですが,重要箇所はuseQueryuseMutationを使うところです.
引数にクエリを渡し,useQueryの場合は戻り値としてレスポンスデータを受け取り,useMutationの場合はGraphQL mutation を実行する関数を受け取ります.

clinet/App.js
import "./App.css";
import { gql, useMutation, useQuery } from "@apollo/client";
import { useEffect, useState } from "react";

const GET_TODOLIST = gql`
  query getAll {
    todoList {
      id
      text
    }
  }
`;
const DELETE_TODO = gql`
  mutation deleteTodoByID($deleteTodoId: ID!) {
    deleteTodo(id: $deleteTodoId)
  }
`;
const UPDATE_TODO = gql`
  mutation createTodo($updateTodoId: ID!, $updateTodoText: String) {
    updateTodo(id: $updateTodoId, text: $updateTodoText)
  }
`;
const CREATE_TODO = gql`
  mutation createTodo($createTodoText: String) {
    createTodo(text: $createTodoText) {
      id
      text
    }
  }
`;

function App() {
  const { data } = useQuery(GET_TODOLIST);
  const [addTodo] = useMutation(CREATE_TODO);
  const [deleteTodo] = useMutation(DELETE_TODO);
  const [updateTodo] = useMutation(UPDATE_TODO);
  const [inputText, setInputText] = useState("");
  const [editingId, setEditingId] = useState(-1);
  const [editingText, setEditingText] = useState("");

  const [todoList, setTodoList] = useState([]);
  useEffect(() => {
    setTodoList(data?.todoList);
  }, [data]);

  const handleCreateChange = (e) => setInputText(e.target.value);
  const handleEditingChange = (e) => setEditingText(e.target.value);

  const handleCreateEnter = (e) => {
    if (e.key === "Enter") {
      handleCreate(inputText);
      setInputText("");
    }
  };
  const handleEditingEnter = (e) => {
    if (e.key === "Enter") {
      handleUpdate(editingId, editingText);
      setEditingId(-1);
    }
  };
  const handleDelete = async (id) => {
    const { data: deleteResponse } = await deleteTodo({
      variables: { deleteTodoId: id },
    });
    if (deleteResponse?.deleteTodo) {
      console.log(`${id}を削除`);
      const index = todoList.findIndex((e) => e.id === id);
      let _todoList = todoList.slice();
      _todoList.splice(index, 1);
      setTodoList(_todoList);
    }
  };
  const handleUpdate = async (id, text) => {
    const { data: updateResonse } = await updateTodo({
      variables: { updateTodoId: id, updateTodoText: text },
    });
    if (updateResonse?.updateTodo) {
      console.log(`${id}を更新`);
      const index = todoList.findIndex((e) => e.id === id);
      let _todoList = todoList.slice();
      _todoList[index] = { id: id, text: text };
      setTodoList(_todoList);
    }
  };
  const handleCreate = async (text) => {
    const { data: createResonse } = await addTodo({
      variables: { createTodoText: text },
    });
    if (createResonse?.createTodo) {
      const addedTodo = createResonse?.createTodo;
      if (!todoList.some((todo) => todo.id === addedTodo.id)) {
        console.log("同一の内容のTODOは無いので新規作成");
        let _todoList = todoList.slice();
        _todoList.push(addedTodo);
        setTodoList(_todoList);
      }
    }
  };
  return (
    <div>
      <h1>今年中に終わらせるTODOリスト</h1>
      <ul>
        {todoList &&
          todoList?.map((todo) => (
            <li key={todo.id}>
              {todo.id}
              {editingId === todo.id ? (
                <>
                  <input
                    type="text"
                    value={editingText}
                    onChange={handleEditingChange}
                    onKeyPress={handleEditingEnter}
                  />
                  <button
                    onClick={() => {
                      handleUpdate(todo.id, editingText);
                      setEditingId(-1);
                    }}>
                    決定
                  </button>
                </>
              ) : (
                <>
                  {todo.text}
                  <button
                    onClick={() => {
                      setEditingId(todo.id);
                      setEditingText(todo.text);
                      setTodoList(todoList);
                    }}>
                    編集するよ
                  </button>
                  <button onClick={() => handleDelete(todo.id)}>
                    消しちゃうよ
                  </button>
                </>
              )}
            </li>
          ))}
      </ul>
      <input
        type="text"
        placeholder="新しいタスク"
        value={inputText}
        onChange={handleCreateChange}
        onKeyPress={handleCreateEnter}
      />
    </div>
  );
}
export default App;

後はclientディレクトリ下で下記のコマンドを実行すればクライアント側も起動します.

npm start

完成したTODOリスト

デプロイはしていないのでローカルでしか動きませんが,無事CRUDを行うTODOアプリを作成することができました!

hogehoge.gif

おまけ:イラストを追加

流石にCSSが何もないと寂しい,けど学習用のTODOアプリにCSSを書き込むのもな...ということで手描きイラストを追加してみました.
最後の</div>の上に以下のコードを追加します.

clinet/App.js
      {todoList?.length === 0 ? (
        <img src={"./GraphQL_2.png"} width="200"></img>
      ) : (
        <img src={"./GraphQL_1.png"} width="200"></img>
      )}

illustration.gif

なんということでしょう.
可愛いイラストを追加するだけで寂しいTODOアプリがこんなに鮮やかに!可愛いイラストって偉大ですね.

画像はコードと一緒にGitHubに上がっているので自由に使用してください.

おまけ2:つまりポイントメモ

今回の作業途中で詰まった点を挙げておきます.

  • 変数名を間違える
    • 似た変数名を付けすぎて書き間違えてしまいました.発見に時間がかかり時間が溶けるので分かりやすい命名の大切さを痛感しました...
  • useMutationの使い方を読み飛ばしていた
    • 関数を返すところを読み飛ばしてしまい,useQueryと同じように使おうとしてエラーを出していました
    • variablesに渡すオブジェクトのkeyを間違えており永遠に新規ToDo作成が行えませんでした

この書き方で合ってるだろうと思い込んでる箇所が要注意ですね.

終わりに

思った以上に動かすまで時間がかかってしまいましたが,その分得られるものは多かったと思います.今後も新しい技術に触れる際には手を動かして簡単なアプリを作成しながら学習したいです.

不備や明らかな誤り等ありましたらご指摘いただけると幸いです.

実際に書いたコード

参考記事

6
5
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
6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?