LoginSignup
161
138

More than 3 years have passed since last update.

GraphQLでタスク管理アプリを作る -フロントエンド編- [React+Apollo Client+Typescript]

Last updated at Posted at 2019-12-21

まえがき

バックエンド編とフロントエンド編の2つに分けて、GraphQL を使ったタスク管理アプリを作っていきます。
このフロントエンド編では、React / Apollo Client / typescript によるGraphQLを使ったTODOアプリの実装をご紹介していきます。

この記事は@ebknさんの記事の後編です。
まだ読んでないというそこのあなた、ぜひご一読ください!(そして願わくばこの記事に戻ってきてくださいな)

このアプリのコードは公開しています。

こんな感じのTODOアプリを作ります。

2019-12-1843442.gif

主な技術要素

Apolloとは

Apollo は、フロントエンド/バックエンド両方に対応したGraphQLフレームワークです。
TECHNOLOGY RADAR (イマドキの技術を半年に一回まとめて紹介しているガイド) の 2019年 4月版 でも ADOPT 、つまり実際の開発現場投入に適している、と紹介されています。

今回はその中のフロントエンド用のフレームワーク、Apollo client を使った開発を紹介します。
これを使うことで、非常に簡単にGraphQL APIを使うことができます。

今回はReactを使いますが、他にもAngular、vueでも似たようなものが存在します。

:warning: 注意:@apollo/react-hooksreact-apollo-hooks

Apollo clientでHooksを使いたい!となって検索すると、たいていこの2つが出てきます。
結論、 @apollo/react-hooksを使ってください。
react-apollo-hooks はまだ公式からhooksサポートが出される前に、有志の方が作ってくださっていたものです。
今は公式がサポートしているので、deprecatedとなっています。
1年くらい前の解説サイトだと、react-apollo-hooksをつかっているサイトもちらほらあるので、混同しないようにお気をつけください。

実現される開発体験

GraphQLの強みである型システムの恩恵を全面に受け、型安全なReactアプリケーションをシュッっと作る。
手動でTypescriptの型定義することを可能な限り減らすことで、ヒューマンエラーの予防にもなる。

ディレクトリ構成

なるべく単純化するために、コンポーネントもそんなに分けていません。
実開発ではもう少し分けたほうがいいと思います。

$ tree -I node_modules
.
├── README.md
├── codegen.yml
├── package-lock.json
├── package.json
├── query.graphql
├── src
│   ├── components
│   │   ├── CompletedIcon.tsx
│   │   ├── CreateTaskModal.tsx
│   │   ├── Tasks.tsx
│   │   └── UpdateTaskModal.tsx
│   ├── generated
│   │   └── graphql.ts
│   ├── hooks
│   │   └── formHooks.ts
│   ├── html
│   │   └── index.html
│   ├── index.tsx
│   ├── lib
│   │   └── sleep.ts
│   ├── styles
│   │   └── main.css
│   └── types
│       └── index.d.ts
├── tsconfig.json
└── webpack.config.js

開発の流れ

  • 1: 必要パッケージのインストール / ビルドツール等の設定
  • 2: GraphQLのスキーマファイルから、GraphQLを使うためのHooks、型定義を自動生成する
  • 3: 自動生成されたコードを使ってReactコンポーネントを作っていく

では早速やっていきましょう!

1: 必要パッケージのインストール / ビルドツール等の設定

必要パッケージをインストールする(後述するpackage.jsonをコピペして npm i でも可)

npm i -D @babel/core @babel/preset-env @babel/preset-react @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo @types/react @types/react-datepicker @types/react-dom @types/react-infinite-scroller @types/react-router @types/react-router-dom" @typescript-eslint/eslint-plugin @typescript-eslint/parser babel-eslint babel-loader babel-polyfill css-loader dotenv-webpack eslint eslint-config-prettier eslint-loader eslint-plugin-graphql eslint-plugin-prettier eslint-plugin-react eslint-plugin-react-hooks html-loader html-webpack-plugin prettier style-loader ts-loader typescript webpack webpack-cli webpack-dev-server && npm i -S @apollo/react-hooks apollo-boost core-js date-fns graphql graphql-tag react react-app-polyfill react-datepicker react-dom react-infinite-scroller react-router react-router-dom semantic-ui-react

たくさんインストールします...ざっくり説明すると以下です。

  • webpack / babel / prettier / eslint / webpackのloader群
    • TypescriptのコードをJSにトランスパイルしたり、css moduleを使ったり、lint/auto-formatしたりするやつ
  • webpack-dev-server
    • hot reload
  • react / react-router等
    • react本体とルーターなど
  • react-infinite-scroller
    • 無限スクロールを簡単にするコンポーネント
  • semantic-ui-react
    • いい感じのフロントを作れるフレームワークの一つ、semantic-uiのreact版
  • apollo-boost / @apollo/react-hooks
    • Apollo client

余談: tslintがdeprecatedになる話

typescriptのlinterとしてtslintをお使いの方は結構いるのではないでしょうか。
実はこのtslint、公式から 2019年に非推奨になる ことがアナウンスされています。

⚠️ TSLint will be deprecated some time in 2019. See this issue for more details: Roadmap: TSLint → ESLint. If you're interested in helping with the TSLint/ESLint migration, please check out our OSS Fellowship program.

このアナウンスにもあるよう、今後はeslintを使っていきましょう。

もうすでにtslintで動いているプロジェクトがある場合は、「どのように移行すべきか」に関して記事を書いてくださっている方がいるので、そちら等を参照ください。
脱TSLintして、ESLint TypeScript Plugin に移行する

筆者もtslintからeslintに移行しましたが、移行するにあって大した手間や不具合は感じなかったです。


codegen.yml (graphql code generatorの設定ファイル)

codegen.yml
schema:
  # GraphQL APIサーバーのエンドポイント
  # この配列に@restや@localを使うクエリファイルを列挙することで、それらに関してもhooksを生成してくれる
  - http://localhost:3000/graphql
# GraphQLのクエリを書いたファイル(詳しくは後述)
documents: ["query.graphql"]
generates:
  # generatorで作成したいファイル名
  ./src/generated/graphql.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo
    config:
      # hooksを生成するための設定
      withHOC: false
      withComponent: false
      withHooks: true
      # gqlgenのcustom scalarをstringとして扱う
      scalars:
        Time: string
    hooks:
      # ファイルが生成されたあとに、eslintのauto-fixを自動で走らせる
      afterOneFileWrite:
        - npx eslint --fix

その他諸々の設定ファイル

package.json
package.json
{
  "name": "graphql-app-advent-calendar-2019",
  "version": "1.0.0",
  "description": "",
  "main": "src/index.tsx",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "serve-dev": "npx webpack-dev-server --config webpack.config.js --inline --hot --port=8081 --content-base dist --open-page ."
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.7.5",
    "@babel/preset-env": "^7.7.6",
    "@babel/preset-react": "^7.7.4",
    "@graphql-codegen/cli": "^1.9.1",
    "@graphql-codegen/typescript": "^1.9.1",
    "@graphql-codegen/typescript-operations": "^1.9.1",
    "@graphql-codegen/typescript-react-apollo": "^1.9.1",
    "@types/react": "^16.9.16",
    "@types/react-datepicker": "^2.9.5",
    "@types/react-dom": "^16.9.4",
    "@types/react-infinite-scroller": "^1.2.1",
    "@types/react-router": "^5.1.3",
    "@types/react-router-dom": "^5.1.3",
    "@typescript-eslint/eslint-plugin": "^2.11.0",
    "@typescript-eslint/parser": "^2.11.0",
    "babel-eslint": "^10.0.3",
    "babel-loader": "^8.0.6",
    "babel-polyfill": "^6.26.0",
    "css-loader": "^3.3.2",
    "dotenv-webpack": "^1.7.0",
    "eslint": "^6.7.2",
    "eslint-config-prettier": "^6.7.0",
    "eslint-loader": "^3.0.3",
    "eslint-plugin-graphql": "^3.1.0",
    "eslint-plugin-prettier": "^3.1.2",
    "eslint-plugin-react": "^7.17.0",
    "eslint-plugin-react-hooks": "^2.3.0",
    "html-loader": "^0.5.5",
    "html-webpack-plugin": "^3.2.0",
    "prettier": "^1.19.1",
    "style-loader": "^1.0.1",
    "ts-loader": "^6.2.1",
    "typescript": "^3.7.3",
    "webpack": "^4.41.3",
    "webpack-cli": "^3.3.10",
    "webpack-dev-server": "^3.9.0"
  },
  "dependencies": {
    "@apollo/react-hooks": "^3.1.3",
    "apollo-boost": "^0.4.7",
    "core-js": "^3.5.0",
    "date-fns": "^2.8.1",
    "graphql": "^14.5.8",
    "graphql-tag": "^2.10.1",
    "react": "^16.12.0",
    "react-app-polyfill": "^1.0.5",
    "react-datepicker": "^2.10.1",
    "react-dom": "^16.12.0",
    "react-infinite-scroller": "^1.2.4",
    "react-router": "^5.1.2",
    "react-router-dom": "^5.1.2",
    "semantic-ui-react": "^0.88.2"
  }
}


.eslintrc.js
eslintrc.js
module.exports = {
  extends: [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "react-hooks",
    "plugin:prettier/recommended",
    "prettier/@typescript-eslint"
  ],
  plugins: ["@typescript-eslint", "react-hooks"],
  overrides: [
    {
        files: ["**/*.tsx"],
        rules: {
            "react/prop-types": "off"
        }
    }
  ],
  parser: "@typescript-eslint/parser",
  env: { browser: true, node: true, es6: true },
  parserOptions: {
    sourceType: "module"
  },
  rules: {
    "@typescript-eslint/explicit-function-return-type": 0,
    "@typescript-eslint/no-explicit-any": 0,
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn",
    "react/display-name": 0,
  }
};


tsconfig.json
tsconfig.json
{
  "compilerOptions": {
    /* Basic Options */
    "target": "es2018" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */,
    "module": "es6" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
    "lib": [
      "es2018",
      "esnext",
      "dom"
    ] /* Specify library files to be included in the compilation. */,
    // "allowJs": true,                       /* Allow javascript files to be compiled. */
    // "checkJs": true,                       /* Report errors in .js files. */
    "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
    // "declaration": true,                   /* Generates corresponding '.d.ts' file. */
    // "declarationMap": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */
    // "sourceMap": true,                     /* Generates corresponding '.map' file. */
    // "outFile": "./",                       /* Concatenate and emit output to single file. */
    // "outDir": "./frontend/dist" /* Redirect output structure to the directory. */,
    // "rootDir": "./frontend/src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
    // "composite": true,                     /* Enable project compilation */
    // "removeComments": true,                /* Do not emit comments to output. */
    // "noEmit": true,                        /* Do not emit outputs. */
    // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */
    // "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
    // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */

    /* Strict Type-Checking Options */
    "strict": true /* Enable all strict type-checking options. */,
    "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
    "strictNullChecks": true /* Enable strict null checks. */,
    "strictFunctionTypes": true /* Enable strict checking of function types. */,
    "strictBindCallApply": true /* Enable strict 'bind', 'call', and 'apply' methods on functions. */,
    "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */,
    "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
    "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,

    /* Additional Checks */
    "noUnusedLocals": true /* Report errors on unused locals. */,
    "noUnusedParameters": true /* Report errors on unused parameters. */,
    "noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
    // "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */

    /* Module Resolution Options */
    "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
    // "baseUrl": "./frontend" /* Base directory to resolve non-absolute module names. */,
    // "paths": {} /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */,
    // "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */
    "typeRoots": [
      "node_modules/@types",
    ] /* List of folders to include type definitions from. */,
    "types": [
      "node"
    ] /* Type declaration files to be included in compilation. */,
    // "allowSyntheticDefaultImports": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
    "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
    // "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */

    /* Source Map Options */
    // "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */
    // "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */
    // "inlineSourceMap": true,               /* Emit a single file with source maps instead of having a separate file. */
    // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */

    /* Experimental Options */
    // "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
    // "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
  }
  // "include": ["frontend/src/**/*"],
  // "exclude": ["node_modules", "frontend/dist"]
}


webpack.config.js
webpack.config.js
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  entry: "./src/index.tsx",
  output: {
    path: path.join(__dirname, "/dist"),
    publicPath: "/",
    filename: "[hash].js"
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "src/html/index.html"
    })
  ],
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".jsx"],
    modules: [path.join(__dirname, "src"), path.join(__dirname, "node_modules")]
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        enforce: "pre",
        exclude: /node_modules/,
        use: ["eslint-loader"]
      },
      {
        test: /\.(ts|tsx)?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: "babel-loader",
            options: {
              presets: [
                ["@babel/preset-react"],
                [
                  "@babel/preset-env",
                  { useBuiltIns: "usage", targets: ">0.25%", corejs: 3 }
                ]
              ]
            }
          },
          {
            loader: "ts-loader",
            options: {
              configFile: "tsconfig.json",
              experimentalWatchApi: true
            }
          }
        ]
      },
      {
        test: /\.css$/,
        loaders: ["style-loader", "css-loader?modules"]
      }
    ]
  }
};


.node-version (nodeのバージョンが8系とかだとGraphql code generatorでエラーが出ました)
12.13.1


2: GraphQLのスキーマファイルから、GraphQLを使うためのHooks、型定義を自動生成する

次に、GraphQL Code Generatorを使ってHooks、型定義を自動生成していきましょう。
これが一連の開発のなかで一番GraphQL!!!神!!!となる瞬間です。

GraphQLのクエリを書いていく

この記事の前編@ebknさんが作ってくれたスキーマを元に、実際に使うクエリを書いていきましょう。
ざっとこんな感じです。

query.graphql
fragment taskFields on Task {
  id
  title
  notes
  completed
  due
}

query fetchTasks(
  $completed: Boolean
  $order: TaskOrderFields!
  $first: Int
  $after: String
) {
  tasks(
    input: { completed: $completed }
    orderBy: $order
    page: { first: $first, after: $after }
  ) {
    pageInfo {
      endCursor
      hasNextPage
    }
    edges {
      cursor
      node {
        ...taskFields
      }
    }
  }
}

mutation createTask(
  $title: String!
  $notes: String
  $completed: Boolean
  $due: Time
) {
  createTask(
    input: { title: $title, notes: $notes, completed: $completed, due: $due }
  ) {
    ...taskFields
  }
}

mutation updateTask(
  $taskID: ID!
  $title: String
  $notes: String
  $completed: Boolean
  $due: Time
) {
  updateTask(
    input: {
      taskID: $taskID
      title: $title
      notes: $notes
      completed: $completed
      due: $due
    }
  ) {
    ...taskFields
  }
}

GraphQL Code Generatorでコードを生成

以下のコマンドでコードを生成します

// APIサーバーを起動
$ cd backend && make start && cd ../frontend

$ npx graphql-codegen
  ✔ Parse configuration
  ✔ Generate outputs

生成されたコードがこちらです!と言いたいところなのですが、300行を超えるファイルなので、一部だけご紹介します。
このアプリのコードは公開しているので、気になる方は見てみてください。

graphql.ts

// ~~~省略~~~
export type Task = Node & {
  __typename?: "Task";
  id: Scalars["ID"];
  title: Scalars["String"];
  notes: Scalars["String"];
  completed: Scalars["Boolean"];
  due?: Maybe<Scalars["Time"]>;
};

// ~~~省略~~~

/**
 * __useFetchTasksQuery__
 *
 * To run a query within a React component, call `useFetchTasksQuery` and pass it any options that fit your needs.
 * When your component renders, `useFetchTasksQuery` returns an object from Apollo Client that contains loading, error, and data properties
 * you can use to render your UI.
 *
 * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
 *
 * @example
 * const { data, loading, error } = useFetchTasksQuery({
 *   variables: {
 *      completed: // value for 'completed'
 *      order: // value for 'order'
 *      first: // value for 'first'
 *      after: // value for 'after'
 *   },
 * });
 */
export function useFetchTasksQuery(
  baseOptions?: ApolloReactHooks.QueryHookOptions<
    FetchTasksQuery,
    FetchTasksQueryVariables
  >
) {
  return ApolloReactHooks.useQuery<FetchTasksQuery, FetchTasksQueryVariables>(
    FetchTasksDocument,
    baseOptions
  );
}

// ~~~省略~~~

最高ですね、Taskの型定義や、fetchTasksのhooksが型付きで生成されています。

ちなみに、 query.graphql を書くときに存在しないフィールドを書いていたり、必須フィールドを飛ばしていたりすると、コード生成のタイミングで以下のように怒ってくれます。

$ npx graphql-codegen
  ✔ Parse configuration
  ❯ Generate outputs
    ❯ Generate ./src/generated/graphql.ts
      ✔ Load GraphQL schemas
      ✔ Load GraphQL documents
      ✖ Generate
        →         at query.graphql:15:3


 Found 1 error

  ✖ ./src/generated/graphql.ts
    AggregateError: 
        GraphQLDocumentError: Field "tasks" argument "input" of type "TasksInput!" is required, but it was not provided.
            at query.graphql:15:3
        at Object.checkValidationErrors (/Users/shoheiihaya/myworks/graphql-app-advent-calendar-2019/frontend/node_modules/@graphql-toolkit/commo
n/index.cjs.js:295:15)
        at Object.codegen (/Users/shoheiihaya/myworks/graphql-app-advent-calendar-2019/frontend/node_modules/@graphql-codegen/core/index.cjs.js:1
01:16)
        at processTicksAndRejections (internal/process/task_queues.js:93:5)
        at async process (/Users/shoheiihaya/myworks/graphql-app-advent-calendar-2019/frontend/node_modules/@graphql-codegen/cli/bin.js:770:56)
        at async Promise.all (index 0)
        at async /Users/shoheiihaya/myworks/graphql-app-advent-calendar-2019/frontend/node_modules/@graphql-codegen/cli/bin.js:777:37
        at async Task.task (/Users/shoheiihaya/myworks/graphql-app-advent-calendar-2019/frontend/node_modules/@graphql-codegen/cli/bin.js:570:17)
    AggregateError: 
        GraphQLDocumentError: Field "tasks" argument "input" of type "TasksInput!" is required, but it was not provided.
            at query.graphql:15:3
        at Object.checkValidationErrors (/Users/shoheiihaya/myworks/graphql-app-advent-calendar-2019/frontend/node_modules/@graphql-toolkit/commo
n/index.cjs.js:295:15)
        at Object.codegen (/Users/shoheiihaya/myworks/graphql-app-advent-calendar-2019/frontend/node_modules/@graphql-codegen/core/index.cjs.js:1
01:16)
        at processTicksAndRejections (internal/process/task_queues.js:93:5)
        at async process (/Users/shoheiihaya/myworks/graphql-app-advent-calendar-2019/frontend/node_modules/@graphql-codegen/cli/bin.js:770:56)
        at async Promise.all (index 0)
        at async /Users/shoheiihaya/myworks/graphql-app-advent-calendar-2019/frontend/node_modules/@graphql-codegen/cli/bin.js:777:37
        at async Task.task (/Users/shoheiihaya/myworks/graphql-app-advent-calendar-2019/frontend/node_modules/@graphql-codegen/cli/bin.js:570:17)


Something went wrong

良い...これでググっとヒューマンエラーが減らせそうです。
いよいよ次の章からコンポーネントを組み立てていきます。

3: 自動生成されたコードを使ってReactコンポーネントを作っていく

3-0: 今回説明しないファイルに関して

この記事ではApollo Clientがメインのため、スタイルやhtmlに関しては説明しません。
リポジトリには上げていますので、必要に応じてご確認ください。

3-1: Apollo Clientの初期化

まずはApollo Clientの初期化です。ついでにReact Routerも初期化します。尚今回使うパスの数は1つです。笑

// src/index.tsx

import React from "react";
import ReactDom from "react-dom";
import { Router } from "react-router";

import ApolloClient from "apollo-boost";
import { ApolloProvider } from "@apollo/react-hooks";

import { createBrowserHistory } from "history";
const history = createBrowserHistory();

// ここでApollo Clientの初期化
const client = new ApolloClient({
  uri: "http://localhost:3000/graphql"
});

export default function App() {
  return (
    <Router history={history}>
      <ApolloProvider client={client}>
        <div>something</div>
      </ApolloProvider>
    </Router>
  );
}

ReactDom.render(<App />, document.getElementById("app"));

GraphQLサーバーのエンドポイントを指定するだけです。
非常にシンプルですね。
もう少し色々書くと、Rest APIをGraphQLのクエリの中で呼び出せるapollo-link-rest用のRest APIエンドポイントもまとめて登録したり、websocketエンドポイントとつなぐこともできます。
もちろん、Authorization ヘッダを指定したり、401が帰ってきたときにjwtトークンを再発行するAPIを叩いてリトライ...なんてことも可能です。
公式のページ①, 公式のページ②等が参考になると思います。

3-2: taskの一覧ページを作る

最初にコンポーネントの全体を貼ります。以下に要所要所で説明していきます。

src/components/Tasks.tsx
// src/components/Tasks.tsx

import React, { useCallback, useState, useEffect, useMemo } from "react";

import {
  Header,
  Icon,
  List,
  Dimmer,
  Loader,
  Dropdown,
  DropdownProps
} from "semantic-ui-react";
import InfiniteScroll from "react-infinite-scroller";
import CreateTaskModal from "./CreateTaskModal";
import UpdateTaskModal from "./UpdateTaskModal";
import CompletedIcon from "./CompletedIcon";

import { formatRelative } from "date-fns";
import ja from "date-fns/locale/ja";
import {
  useFetchTasksQuery,
  TaskOrderFields,
  Task
} from "../generated/graphql";
import styles from "../styles/main.css";

type TaskFilterType = "all" | "completed" | "notCompleted";
const Tasks = () => {
  const [selectedTask, setSelectedTask] = useState<Task>();
  const [fetchMoreLoading, setFetchMoreLoading] = useState(false);
  const [taskFilterType, setTaskFilterType] = useState<TaskFilterType>("all");
  const [orderType, setOrderType] = useState<TaskOrderFields>(
    TaskOrderFields.Latest
  );

  const handleTaskFilterTypeChange = useCallback(
    (_: React.SyntheticEvent<HTMLElement, Event>, data: DropdownProps) => {
      setTaskFilterType(data.value as TaskFilterType);
    },
    []
  );
  const completedInput = useMemo(() => {
    switch (taskFilterType) {
      case "all":
        return null;
      case "completed":
        return true;
      case "notCompleted":
        return false;
    }
  }, [taskFilterType]);

  const handleOrderTypeChange = useCallback(
    (_: React.SyntheticEvent<HTMLElement, Event>, data: DropdownProps) => {
      setOrderType(data.value as TaskOrderFields);
    },
    []
  );

  const { data, error, fetchMore, refetch } = useFetchTasksQuery({
    variables: { order: TaskOrderFields.Latest, first: 5 },
    fetchPolicy: "cache-and-network"
  });

  useEffect(() => {
    refetch({ order: orderType, completed: completedInput, first: 5 });
  }, [completedInput, orderType, refetch]);

  const refetchAfterAdd = useCallback(() => {
    refetch({ order: orderType, completed: completedInput, first: 5 });
  }, [completedInput, orderType, refetch]);

  const handleLoadMore = useCallback(async () => {
    if (data && !fetchMoreLoading) {
      setFetchMoreLoading(true);
      await fetchMore({
        variables: {
          after: data.tasks.pageInfo.endCursor,
          order: orderType,
          completed: completedInput,
          first: 5
        },
        updateQuery: (previousResult, { fetchMoreResult }) => {
          if (!fetchMoreResult) {
            return previousResult;
          }

          const newEdges = fetchMoreResult.tasks.edges;
          const pageInfo = fetchMoreResult.tasks.pageInfo;

          return {
            tasks: {
              ...previousResult.tasks,
              pageInfo,
              edges: [...previousResult.tasks.edges, ...newEdges]
            }
          };
        }
      });
      setFetchMoreLoading(false);
    }
  }, [completedInput, data, fetchMore, fetchMoreLoading, orderType]);

  const handleListItemClick = useCallback(
    (subscriber: Task) => () => {
      setSelectedTask(subscriber);
    },
    []
  );

  const handleModalClose = useCallback(() => {
    setSelectedTask(undefined);
  }, []);

  if (!data) {
    return (
      <Dimmer active={true}>
        <Loader>ロード中...</Loader>
      </Dimmer>
    );
  }

  if (error) {
    return <div>エラー</div>;
  }

  return (
    <div className={styles.main_content_box}>
      <Header color="teal" icon={true} textAlign="center">
        <Icon name="tasks" />
        <Header.Content>TODOs</Header.Content>
      </Header>
      <Dropdown
        options={[
          { value: "all", text: "すべて" },
          { value: "notCompleted", text: "未完了" },
          { value: "completed", text: "完了済み" }
        ]}
        value={taskFilterType}
        onChange={handleTaskFilterTypeChange}
        fluid={true}
        selection={true}
      />
      <div className={styles.order_dropdown}>
        <Dropdown
          options={[
            { value: TaskOrderFields.Due, text: "期限順" },
            { value: TaskOrderFields.Latest, text: "作成順" }
          ]}
          icon="sort amount up"
          value={orderType}
          onChange={handleOrderTypeChange}
        />
      </div>
      <InfiniteScroll
        loadMore={handleLoadMore}
        hasMore={data.tasks.pageInfo.hasNextPage}
        loader={
          <p style={{ textAlign: "center" }} key={0}>
            <Icon loading={true} name="spinner" />
          </p>
        }
      >
        <List selection={true} divided={true}>
          {data.tasks.edges.map(task =>
            task ? (
              <List.Item key={task.node.id}>
                <CompletedIcon task={task.node} />
                <List.Content onClick={handleListItemClick(task.node)}>
                  <List.Header>{task.node.title}</List.Header>
                  {task.node.due ? (
                    <List.Description>
                      <Icon name="time" />
                      {formatRelative(new Date(task.node.due), new Date(), {
                        locale: ja
                      })}{" "}
                      まで
                    </List.Description>
                  ) : null}
                </List.Content>
              </List.Item>
            ) : null
          )}
        </List>
      </InfiniteScroll>
      <CreateTaskModal refetch={refetchAfterAdd} />
      {selectedTask !== undefined ? (
        <UpdateTaskModal
          task={selectedTask}
          handleModalClose={handleModalClose}
        />
      ) : null}
    </div>
  );
};
export default Tasks;


いきなり長いですね〜、少しずつ紐解いていきましょう。

3-2-1: taskのシンプルな取得

まずはTasksを取得する部分です。

  const { data, error, fetchMore, refetch } = useFetchTasksQuery({
    variables: { order: TaskOrderFields.Latest, first: 5 },
    fetchPolicy: "cache-and-network"
  });

useFetchTasksQuery が前のステップで自動生成してくれたHooksですね。
この引数として、queryの変数やfetchPolicy(キャッシュを優先するか、postするかを決める)を指定しています。
queryの変数というのは、 query.graphql で言うところのこれ↓です。
今回はpaginationのための変数です。
graphQLの仕様として、!がついてないフィールドは nullable ということで、省略しても大丈夫です。

# ~~ 省略 ~~

query fetchTasks(
  $completed: Boolean            <--- これ!
  $order: TaskOrderFields!       <--- これ!
  $first: Int                    <--- これ!
  $after: String                 <--- これ!
) {
  tasks(orderBy: $order, page: { first: $first, after: $after }) {

# ~~ 省略 ~~

このHooksはコンポーネントが初期化するタイミングや、variablesの中身が変わったタイミングでfetchします。
fetchした結果やエラーの有無、今回は取っていませんがloadingなどが返り値としてゲットできます。(fetchMore, refetchに関しては後述)
ほしいtasksのデータはdataの中です。

次に、ロード状態の表示や、エラーの表示を記述します。
本来はloadingをhooksからもらうのが一般的ですが、諸々の都合で今回はdataが空の間はロード中とみなします。

// ~~ 省略 ~~

if (!data) {
    return (
      <Dimmer active={true}>
        <Loader>ロード中...</Loader>
      </Dimmer>
    );
  }

  if (error) {
    return <div>エラー</div>;
  }

// ~~ 省略 ~~

それができたら次は、取得したtasksをリストとして表示しましょう。
配列として入っているので、シンプルにmapすれば完了です。
CompletedIconという謎のコンポーネントや、List.Contentに渡しているハンドラに関しては後述します。

// ~~ 省略 ~~
        <List selection={true} divided={true}>
          {data.tasks.edges.map(task =>
            task ? (
              <List.Item key={task.node.id}>
                <CompletedIcon task={task.node} />
                <List.Content onClick={handleListItemClick(task.node)}>
                  <List.Header>{task.node.title}</List.Header>
                  {task.node.due ? (
                    <List.Description>
                      <Icon name="time" />
                      {/* date-fnsのformatRelative関数は 「あとOO日」や「OO日前に投稿」のような相対的な時間をいい感じに表示してくれる。localオプションによるローカライズも可能。 */}
                      {formatRelative(new Date(task.node.due), new Date(), {
                        locale: ja
                      })}{" "}
                      まで
                    </List.Description>
                  ) : null}
                </List.Content>
              </List.Item>
            ) : null
          )}
        </List>

// ~~ 省略 ~~

ここまでで、シンプルな一覧の取得・表示はできました!

3-2-2: 無限スクロールでタスクを表示する

先程のHooksが返してくれた値の中に、fetchMore という関数があります。
これはPaginationのための関数で、これを呼び出すことで更にタスクを取得してきてくれます。
実際に使っている部分はここ↓です。再取得時の変数(variables)と、取得した際にどのように既存データとマージするかを定義した関数(updateQuery)を渡します。

// ~~ 省略 ~~
await fetchMore({
        variables: {
          after: data.tasks.pageInfo.endCursor,
          order: orderType,
          completed: completedInput,
          first: 5
        },
        updateQuery: (previousResult, { fetchMoreResult }) => {
          // previousResultがもともとのデータ、fetchMoreResultは再取得結果
          if (!fetchMoreResult) {
            return previousResult;
          }

          const newEdges = fetchMoreResult.tasks.edges;
          const pageInfo = fetchMoreResult.tasks.pageInfo;

          // 新しいデータを組み立てて返す
          return {
            tasks: {
              ...previousResult.tasks,
              pageInfo,
              edges: [...previousResult.tasks.edges, ...newEdges]
            }
          };
        }
// ~~ 省略 ~~

一旦これを定義すれば、あとはこの関数を再取得したいタイミングで呼び出すだけです。
今回はreact-infinite-scrollerを使っています。

// ~~ 省略 ~~
      <InfiniteScroll
        // loadMoreが再取得用の関数
        loadMore={handleLoadMore}
        hasMore={data.tasks.pageInfo.hasNextPage}
        loader={
          <p style={{ textAlign: "center" }} key={0}>
            <Icon loading={true} name="spinner" />
          </p>
        }
      >
// ~~ 省略 ~~

無限スクロールとかそれっぽくていいですね〜
次はフィルタリングと並び替えをやっていきます!

3-2-3: タスクのフィルタリングと並び替え

これも考え方は同じで、フィルタするための変数と並び替え用の変数をgraphQLに渡してfetchすればOKです。
ここでは、Hooksが返してくれるrefetchを使っていきます。その名の通り、読んだタイミングで再取得する関数です。
useEffectを使って、変数が変わったときにrefetchを呼んでいます。

// ~~ 省略 ~~
  useEffect(() => {
    refetch({ order: orderType, completed: completedInput, first: 5 });
  }, [completedInput, orderType, refetch]);
// ~~ 省略 ~~

いい感じに一覧を実装することができました!続いてタスクの作製用コンポーネントを作りましょう。

3-3: タスクの作成

CreateTaskModal.tsx
import React, { useCallback, useState } from "react";

import {
  Form,
  Modal,
  Button,
  Icon,
  Message,
  Checkbox
} from "semantic-ui-react";

import { useCreateTaskMutation } from "../generated/graphql";

import { useTaskFields } from "../hooks/formHooks";
import sleep from "../lib/sleep";
import styles from "../styles/main.css";
import "react-datepicker/dist/react-datepicker-cssmodules.css";
import DatePicker, { registerLocale } from "react-datepicker";
import ja from "date-fns/locale/ja";
registerLocale("ja", ja);

interface Props {
  refetch: () => void;
}

const CreateTaskModal = ({ refetch }: Props) => {
  const {
    titleProps,
    notesProps,
    completedProps,
    dueProps,
    clearValue
  } = useTaskFields();

  const [success, setSuccess] = useState(false);
  const [open, setOpen] = useState(false);

  const handleMutationCompleted = useCallback(async () => {
    setSuccess(true);
    refetch();
    await sleep(1500);
    clearValue();
    setOpen(false);
    setSuccess(false);
  }, [clearValue, refetch]);

  const [createTask, { loading, error }] = useCreateTaskMutation({
    variables: {
      title: titleProps.value,
      notes: notesProps.value,
      completed: completedProps.checked,
      due: dueProps.selected?.toISOString()
    },
    onCompleted: handleMutationCompleted
  });

  const handleButtonClick = useCallback(() => {
    createTask();
  }, [createTask]);

  const handleOpen = useCallback(() => {
    setOpen(true);
  }, []);
  const handleClose = useCallback(() => {
    setOpen(false);
  }, []);

  return (
    <Modal
      open={open}
      closeIcon={true}
      onClose={handleClose}
      onOpen={handleOpen}
      trigger={
        <div className={styles.add_button}>
          <Button
            icon={true}
            size="tiny"
            basic={true}
            circular={true}
            positive={true}
          >
            <Icon name="plus" />
          </Button>
        </div>
      }
    >
      <Modal.Header>タスクを追加</Modal.Header>
      <Modal.Content>
        <Form loading={loading} success={success} error={!!error}>
          <Message error={true}>追加中にエラーが発生しました</Message>
          <Message success={true}>タスクを追加しました</Message>
          <Form.Field required={true}>
            <label>タスク名</label>
            <Form.Input
              placeholder="ピーマンを買いに行く"
              type="text"
              required={true}
              {...titleProps}
            />
          </Form.Field>
          <Form.Field>
            <label>メモ</label>
            <Form.Input
              placeholder="駅前のOKストアがマジで安い"
              type="text"
              {...notesProps}
            />
          </Form.Field>
          <Form.Field>
            <label>完了</label>
            <Checkbox {...completedProps} />
          </Form.Field>
          <Form.Field>
            <label>期限</label>
            <DatePicker {...dueProps} locale="ja" dateFormat="yyyy/MM/dd" />
          </Form.Field>
        </Form>
      </Modal.Content>
      <Modal.Actions>
        <Button
          icon={true}
          onClick={handleButtonClick}
          positive={true}
          disabled={titleProps.value === ""}
        >
          <Icon name="plus" /> 追加する
        </Button>
      </Modal.Actions>
    </Modal>
  );
};
export default CreateTaskModal;


作成するときは、 useCreateTaskMutation を使います。
useFetchTasksQueryの同じように、variableにqueryの変数を渡します。
今回はonCompetedに関数を指定し、mutation終了後(作成完了後)にする処理を決めています。
handleMutationCompletedの中ではrefetch、formの値のクリアなどを行っています。

// ~~ 省略 ~~
  const handleMutationCompleted = useCallback(async () => {
    setSuccess(true);
    refetch();
    await sleep(1500);
    clearValue();
    setOpen(false);
    setSuccess(false);
  }, [clearValue, refetch]);

// ~~ 省略 ~~

  const [createTask, { loading, error }] = useCreateTaskMutation({
    variables: {
      title: titleProps.value,
      notes: notesProps.value,
      completed: completedProps.checked,
      due: dueProps.selected?.toISOString()
    },
    onCompleted: handleMutationCompleted
  });
// ~~ 省略 ~~

なぜrefetchをするのか? / mutation実行時のキャッシュの更新に関して

Apollo Clientは、更新のmutationを走らせたときは自動で新しい値にキャッシュを更新してくれます。(一覧に表示されている更新したタスクが新しい値に書き換わる)
ですが、新規作成・削除した場合は自動で更新されません。

もちろん画面をreloadすればそのタイミングでfetchが走るので、新しい値に書き換わります。ですが、UX的にはmutationを走らせたときにキャッシュが更新されたほうが良いでしょう。
それを実現するために、mutationのHooksはupdate関数を引数として受け取ります。
その関数の中で手動でキャッシュを書き換えます。イメージとしては、先程実装したfetchMoreと同じような感じです。

以下のupdate関数は正しく動かないので、コードの雰囲気を感じるだけにしてください。

    update: (cache, { data }) => {
      if (!data) return;

      // 新しく作られたタスク
      const createdTask = data.createTask;

    // readQuery関数で既存のキャッシュを取ってくる
      const tasksQuery = cache.readQuery<
        FetchTasksQuery,
        FetchTasksQueryVariables
      >({
        query: FetchTasksDocument,
        variables: { ...fetchTaskParam }
      });

      if (!tasksQuery) return;

    // writeQuery関数で既存のキャッシュに新しいデータをマージして書き込む
      cache.writeQuery<FetchTasksQuery, FetchTasksQueryVariables>({
        query: FetchTasksDocument,
        variables: { ...fetchTaskParam },
        data: {
          ...tasksQuery,
          tasks: {
            ...tasksQuery.tasks,
            edges: [
              ...tasksQuery.tasks.edges,
              {
                node: createTask
              }
            ]
          }
        }
      });
    }

一見、単純なように見えますが、これがなかなか難しい問題を抱えています。

  1. connectionの形式でキャッシュに入れないといけないが、cursorとかはサーバーから貰っていない
  2. ソートや絞り込みがかかった状態で、どこに新しいタスクを追加すべきか問題がある(ただ末尾につけるだけでいいのか?)

1に関しては必要な情報をサーバー側に返してもらう、というのが解決策の一つです。このサイトなどで紹介されています。
2は同じようなことを議論しているフォーラムがありますが、特に結論は出ていないようです。。。
ソートとかをフロント側でやるのも違う。。。

シンプルな一覧表示においてはupdate関数を使うのがベストですが、このような微妙な場合は多少のオーバーヘッドを犠牲にrefetchするのもありかな、ということで今回はシンプルにrefetchしています。

3-4: タスクの更新

UpdateTaskModal.tsx
import React, { useCallback, useState } from "react";

import {
  Form,
  Modal,
  Button,
  Icon,
  Message,
  Checkbox
} from "semantic-ui-react";
import { useUpdateTaskMutation, Task } from "../generated/graphql";

import { useTaskFields } from "../hooks/formHooks";
import sleep from "../lib/sleep";
import "react-datepicker/dist/react-datepicker-cssmodules.css";
import DatePicker, { registerLocale } from "react-datepicker";
import ja from "date-fns/locale/ja";
registerLocale("ja", ja);

interface Props {
  task: Task;
  handleModalClose: () => void;
}

const CreateTaskModal = ({ task, handleModalClose }: Props) => {
  const {
    titleProps,
    notesProps,
    completedProps,
    dueProps,
    clearValue
  } = useTaskFields(task);

  const [success, setSuccess] = useState(false);

  const handleMutationCompleted = useCallback(async () => {
    setSuccess(true);
    await sleep(1500);
    clearValue();
    handleModalClose();
  }, [clearValue, handleModalClose]);

  const [updateTask, { loading, error }] = useUpdateTaskMutation({
    variables: {
      taskID: task.id,
      title: titleProps.value,
      notes: notesProps.value,
      completed: completedProps.checked,
      due: dueProps.selected?.toISOString()
    },
    onCompleted: handleMutationCompleted
  });

  const handleButtonClick = useCallback(() => {
    updateTask();
  }, [updateTask]);


  return (
    <Modal open={!!task} closeIcon={true} onClose={handleModalClose}>
      <Modal.Header>タスクを編集</Modal.Header>
      <Modal.Content>
        <Form loading={loading} success={success} error={!!error}>
          <Message error={true}>保存中にエラーが発生しました</Message>
          <Message success={true}>タスクを編集しました</Message>
          <Form.Field required={true}>
            <label>タスク名</label>
            <Form.Input
              placeholder="ピーマンを買いに行く"
              type="text"
              required={true}
              {...titleProps}
            />
          </Form.Field>
          <Form.Field>
            <label>メモ</label>
            <Form.Input
              placeholder="駅前のOKストアがマジで安い"
              type="text"
              {...notesProps}
            />
          </Form.Field>
          <Form.Field>
            <label>完了</label>
            <Checkbox {...completedProps} />
          </Form.Field>
          <Form.Field>
            <label>期限</label>
            <DatePicker {...dueProps} locale="ja" dateFormat="yyyy/MM/dd" />
          </Form.Field>
        </Form>
      </Modal.Content>
      <Modal.Actions>
        <Button
          icon={true}
          onClick={handleButtonClick}
          positive={true}
          disabled={titleProps.value === ""}
        >
          <Icon name="plus" /> 保存する
        </Button>
      </Modal.Actions>
    </Modal>
  );
};
export default CreateTaskModal;


CompletedIcon.tsx
import React from "react";

import { Icon, Message } from "semantic-ui-react";
import { useUpdateTaskMutation, Task } from "../generated/graphql";

interface Props {
  task: Task;
}

const CreateTaskModal = ({ task }: Props) => {
  const [updateTask, { error }] = useUpdateTaskMutation({
    variables: {
      taskID: task.id,
      completed: !task.completed
    }
  });

  if (error) {
    return <Message error={true}>更新に失敗しました</Message>;
  }

  return task.completed ? (
    <Icon name="check circle" color="green" size="big" onClick={updateTask} />
  ) : (
    <Icon name="check circle" size="big" onClick={updateTask} />
  );
};
export default CreateTaskModal;


最後に、タスクの更新です。
今回は、一覧画面上のチェックアイコンをクリックすると完了・未完了のtoggleができる機能と、モーダルを出して諸項目をまとめて更新するものを作っています。

更新は、 useUpdateTaskMutation を使います。このHooksの使い方はcreateとだいたい同じです。

// UpdateTaskModal.tsx

  const [updateTask, { loading, error }] = useUpdateTaskMutation({
    variables: {
      taskID: task.id,
      title: titleProps.value,
      notes: notesProps.value,
      completed: completedProps.checked,
      due: dueProps.selected?.toISOString()
    },
    onCompleted: handleMutationCompleted
  });

// CompletedIcon.tsx
  const [updateTask, { error }] = useUpdateTaskMutation({
    variables: {
      taskID: task.id,
      completed: !task.completed
    }
  });

更新の場合はキャッシュが自動で書き換わるので、update等を気にする必要はありません!これで一通り完成です!


ここまでで、React/Apollo Client/Typescriptを使ったフロントエンド実装ができました。
型定義を自分でする必要なく、Hooksで取得できる値に型が付いていることでエディタの補完もはかどり、書き心地は抜群です。
まだ発展途上の技術ではありますが、どんどんキャッチアップして、素敵なGraphQLライフを送っていきましょう。

161
138
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
161
138