React+Amplify+AppSync+TypeScriptでリアルタイム掲示板アプリを作る

![amplify_react_ts.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189588/30cda436-a626-bb66-d8dc-dc5d674769c7.png)

この記事は、「[【爆速】React+Amplify+AppSyncでリアルタイム掲示板アプリを15分で作り上げる　〜これが最高のDeveloper Experienceだ〜 - Qiita](https://qiita.com/G-awa/items/a5b2cc7017b1eceeb002)」を参考にさせて頂きました。

Amplifyのコマンドでコードを自動生する際にTypeScriptを選択できるようなので、どんな感じなのか試してみました。
ついでに、ReactのHooksも使ってます。

## バージョン

使用した環境は以下のとおりです。

```sh
$ create-react-app --version
3.0.1
$ node -v
v8.15.1
$ npm -v
6.9.0
$ amplify --version
1.7.0
```

自分の環境にはamplifyのコマンドすら入っていない状態だったので、公式のページを見てインストールしました。

[Getting Started · Create React App](https://facebook.github.io/create-react-app/docs/getting-started)

## Reactアプリの雛形を作る

`create-react-app`で引数に`--typescript`を指定して作成し、`amplify init`で初期化設定をしていきます。
`profile`の指定等は適宜変更して下さい。

```sh
$ create-react-app boardapp --typescript
$ cd boardapp
$ amplify init
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project boardapp
? Enter a name for the environment dev
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using react
? Source Directory Path:  src
? Distribution Directory Path: build
? Build Command:  npm run-script build
? Start Command: npm run-script start
? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default
```

GraphQLのAPIを追加します。

```sh
$ amplify add api
? Please select from one of the below mentioned services GraphQL
? Provide API name: boardapp
? Choose an authorization type for the API API key
? Do you have an annotated GraphQL schema? No
? Do you want a guided schema creation? No
? Provide a custom type name Post
```

以下のスキーマのサンプルが出来るので、そのまま使います。

```graphql:amplify/backend/api/schema.graphql
type Post @model {
	id: ID!
	title: String!
	content: String!
	price: Int
	rating: Float
}
```

次に、デプロイとクライアントのコードの自動生成をします。

```sh
$ amplify push
? Are you sure you want to continue? Yes
? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target typescript
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.ts
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
? Enter the file name for the generated code src/API.ts
```

ここまで終わると、GraplQLのAPIがAWSにデプロイされ、ローカルのディレクトリは以下のような構成になりました。

```sh
$ tree -L 5 -I "node_modules"
.
├── README.md
├── amplify
│   ├── #current-cloud-backend
│   │   ├── amplify-meta.json
│   │   ├── api
│   │   │   └── boardapp
│   │   │       ├── build
│   │   │       ├── parameters.json
│   │   │       ├── resolvers
│   │   │       ├── schema.graphql
│   │   │       └── stacks
│   │   └── backend-config.json
│   ├── backend
│   │   ├── amplify-meta.json
│   │   ├── api
│   │   │   └── boardapp
│   │   │       ├── build
│   │   │       ├── parameters.json
│   │   │       ├── resolvers
│   │   │       ├── schema.graphql
│   │   │       └── stacks
│   │   ├── awscloudformation
│   │   │   └── nested-cloudformation-stack.yml
│   │   └── backend-config.json
│   └── team-provider-info.json
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   └── manifest.json
├── src
│   ├── API.ts
│   ├── App.css
│   ├── App.test.tsx
│   ├── App.tsx
│   ├── aws-exports.js
│   ├── graphql
│   │   ├── mutations.ts
│   │   ├── queries.ts
│   │   ├── schema.json
│   │   └── subscriptions.ts
│   ├── index.css
│   ├── index.tsx
│   ├── logo.svg
│   ├── react-app-env.d.ts
│   └── serviceWorker.ts
├── tsconfig.json
└── yarn.lock
```

## amplifyのパッケージ追加

yarnでパッケージを登録します。
TypeScriptの型も一緒にに登録されるようです。

```sh
$ yarn add aws-amplify aws-amplify-react
```

## アプリケーションの更新

`create-react-app`で自動生成されたコードを変更していきます。
まず、Amplifyの初期化部分です。

```tsx:src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import Amplify from "aws-amplify"  // 追加
import config from "./aws-exports" // 追加
Amplify.configure(config)          // 追加

ReactDOM.render(<App />, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
```

次に、アプリ本体です。ポイントは後ほど解説します。
また、流れを掴む事が目的のため、エラー処理は入れてません。

```tsx:src/App.tsx
import React, { useEffect, useState } from "react";
import { API, graphqlOperation } from "aws-amplify";
import { listPosts } from "./graphql/queries";
import { createPost } from "./graphql/mutations";
import { onCreatePost } from "./graphql/subscriptions";
import {
  ListPostsQuery,
  OnCreatePostSubscription,
  CreatePostMutationVariables
} from "./API";

type Post = {
  id: string;
  title: string;
  content: string;
  price: number | null;
  rating: number | null;
};

type FormState = {
  title: string;
  content: string;
};

type PostSubscriptionEvent = { value: { data: OnCreatePostSubscription } };

const usePosts = () => {
  const [posts, setPosts] = useState<Post[]>([]);

  useEffect(() => {
    (async () => {
      // 最初のPost一覧取得
      const result = await API.graphql(graphqlOperation(listPosts));
      if ("data" in result && result.data) {
        const posts = result.data as ListPostsQuery;
        if (posts.listPosts) {
          setPosts(posts.listPosts.items as Post[]);
        }
      }

      // Post追加イベントの購読
      const client = API.graphql(graphqlOperation(onCreatePost));
      if ("subscribe" in client) {
        client.subscribe({
          next: ({ value: { data } }: PostSubscriptionEvent) => {
            if (data.onCreatePost) {
              const post: Post = data.onCreatePost;
              setPosts(prev => [...prev, post]);
            }
          }
        });
      }
    })();
  }, []);

  return posts;
};

const App: React.FC = () => {
  const [input, setInput] = useState<FormState>({
    title: "",
    content: ""
  });
  const posts = usePosts();

  const onFormChange = ({
    target: { name, value }
  }: React.ChangeEvent<HTMLInputElement>) => {
    setInput(prev => ({ ...prev, [name]: value }));
  };

  const onPost = () => {
    if (input.title === "" || input.content === "") return;
    const newPost: CreatePostMutationVariables = {
      input: {
        title: input.title,
        content: input.content
      }
    };
    setInput({ title: "", content: "" });
    API.graphql(graphqlOperation(createPost, newPost));
  };

  return (
    <div className="App">
      <div>
        タイトル
        <input value={input.title} name="title" onChange={onFormChange} />
      </div>
      <div>
        内容
        <input value={input.content} name="content" onChange={onFormChange} />
      </div>
      <button onClick={onPost}>追加</button>
      <div>
        {posts.map(data => {
          return (
            <div key={data.id}>
              <h4>{data.title}</h4>
              <p>{data.content}</p>
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default App;
```

あとは、起動するだけです。

```sh
$ yarn start
```

![board.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189588/231071f8-5c9a-a652-ee2c-a85cf5941251.png)

複数画面開くと、同時にリアルタイムで更新されます。

## 解説と感想

### モデル

graphqlのスキーマに対応した型が`src/API.ts`に自動生成されているので、基本的にここに定義されている型を使います。

```ts:src/API.ts
export type ListPostsQuery = {
  listPosts:  {
    __typename: "ModelPostConnection",
    items:  Array< {
      __typename: "Post",
      id: string,
      title: string,
      content: string,
      price: number | null,
      rating: number | null,
    } | null > | null,
    nextToken: string | null,
  } | null,
};

export type OnUpdatePostSubscription = {
  onUpdatePost:  {
    __typename: "Post",
    id: string,
    title: string,
    content: string,
    price: number | null,
    rating: number | null,
  } | null,
};
```

Postの中身のみの型が無かったので、以下のように独自に定義しています。

```tsx:src/App.tsx
type Post = {
  id: string;
  title: string;
  content: string;
  price: number | null;
  rating: number | null;
};
```

GraphQLのスキーマそのままなので、自動生成されて欲しい気もします。


## 登録

追加ボタンを押したときに呼ばれるメソッドです。

```tsx:src/App.tsx
  const onPost = () => {
    if (input.title === "" || input.content === "") return;
    const newPost: CreatePostMutationVariables = {
      input: {
        title: input.title,
        content: input.content
      }
    };
    setInput({ title: "", content: "" });
    API.graphql(graphqlOperation(createPost, newPost));
  };
```

GraphQLの代表的なクエリが自動生成されているので、`graphqlOperation`に指定することで、クエリの種類を切り替えられます。ここでは新規登録なので、`createPost`を使います。

```ts:src/graphql/mutations.ts
export const createPost = `mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    content
    price
    rating
  }
}
`;
```

追加するときの引数`$input: CreatePostInput!`に対応する型も自動生成されているので、これに登録したいデータを設定してクエリを送信するだけです。

```ts:src/API.ts
export type CreatePostInput = {
  id?: string | null,
  title: string,
  content: string,
  price?: number | null,
  rating?: number | null,
};

export type CreatePostMutationVariables = {
  input: CreatePostInput,
};
```

### 一覧取得とデータ登録の監視

登録されたデータの一覧取得と追加されたデータの監視は、カスタムフックを作って実現しています。

`useEffect`でコンポーネントのマウント時に、Postの一覧取得、Post作成の購読を追加を順番に行い、`useState`で作成したPost一覧を戻り値として返す事で、Post一覧の更新を伝えます。

```tsx:src/App.tsx
type PostSubscriptionEvent = { value: { data: OnCreatePostSubscription } };

const usePosts = () => {
  const [posts, setPosts] = useState<Post[]>([]);

  useEffect(() => {
    (async () => {
      // 最初のPost一覧取得
      const result = await API.graphql(graphqlOperation(listPosts));
      if ("data" in result && result.data) {
        const posts = result.data as ListPostsQuery;
        if (posts.listPosts) {
          setPosts(posts.listPosts.items as Post[]);
        }
      }

      // Post追加イベントの購読
      const client = API.graphql(graphqlOperation(onCreatePost));
      if ("subscribe" in client) {
        client.subscribe({
          next: ({ value: { data } }: PostSubscriptionEvent) => {
            if (data.onCreatePost) {
              const post: Post = data.onCreatePost;
              setPosts(prev => [...prev, post]);
            }
          }
        });
      }
    })();
  }, []);

  return posts;
};
```

型の整合性を取るため、少しややこしいです。

`API.graphql`の戻り値の型は `Promise<GraphQLResult> | Observable<object>`となっています。
引数の`graphqlOperation`の内容によって戻り値の型が変わります。また、戻り値のデータ型が`object`で、ジェネリックで型の指定も出来ないので、所々`if`で型を絞り込んだり、`as`でキャストしてます。

もっとうまい使い方があるのかもしれませんが、もう少し使いやすくならないかなーと思いました。

## さいごに

最初は全体のイメージが掴みづらかったのですが、実際に使ってみると
思ったよりも簡単にGraphQLのAPIが作れました。
DynamoDB以外にもRDBやLambdaとも連携できるようなので、色々と応用も出来て便利そうです。