React+Amplify+AppSync+TypeScript+Recoilで認証機能つきチャットアプリを作る

React+Amplify+AppSync+TypeScript+Recoilで認証機能つきチャットアプリを作る方法を紹介します。

完成するアプリのデモは以下です。
左右の画面に異なるユーザーでログインし、チャットを行っています。
![ezgif.com-gif-maker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123126/2b8af63b-b940-9404-ff8f-8b38adf2f1f9.gif)

本記事で作成するアプリのアーキテクチャーは下記です。
Amplify Console Static Web Site Hostingでフロンドエンドのコードをホスティングします。
AWS AppsyncでGraphQL APIを提供し、データベースはDynamoDBを使用します。
Amazon Cognitoをユーザー認証に用いています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123126/7e96f791-01cd-94a5-3efd-bdb30810a014.png)


# バージョン
使用した環境は以下の通りです。

```
$ npx create-react-app --version
4.0.3
$ node -v
v14.16.0
$ npm -v
6.14.11
$ amplify -v
4.44.2
```
Amplify CLIが未インストールの場合は、公式ドキュメントを参考にインストールします。

https://docs.amplify.aws/cli/start/install

# アプリの雛形作成
`create-react-app`でアプリの雛形を作ります。

```
$ npx create-react-app chat --template typescript
```
`yarn start`でサンプルアプリが起動すれば成功です。

```
$ cd chat
$ yarn start
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123126/fa6b8613-fb09-1782-7aff-ac127c5f212a.png)

続いて、`amplify init`でプロジェクトにAmplify用の設定を追加します。

```
$ amplify init
? Enter a name for the project chat
? Enter a name for the environment production
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building javascript
? 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 ← amplify configureで指定したプロファイル名を指定
```

アプリの雛形作成は以上で完了です。

# 認証機能の実装
認証機能を実装していきます。

## バックエンド/インフラ
`amplify add auth`で認証機能を追加します。

```
$ amplify add auth
? Do you want to use the default authentication and security configuration? Default configuration
? Warning: you will not be able to edit these selections.
? How do you want users to be able to sign in? Username
? Do you want to configure advanced settings? No, I am done.
```

続いて、`amplify push`でクラウドへ変更を反映します。

```
$ amplify push
? Are you sure you want to continue? Yes
```
バックエンド/インフラの実装は以上で完了です。

## フロントエンド
### パッケージインストール

まずはAmplify関連のパッケージをインストール。

```
$ yarn add aws-amplify @aws-amplify/ui-react
```

続いて、Material-UIをインストール。

```
$ yarn add @material-ui/core @material-ui/icons
```

最後に、Recoilをインストール。

```
$ yarn add recoil
```

### コンポーネントの実装
`App.tsx`を下記のように書き換えます。
ログイン画面は`@aws-amplify/ui-react`のコンポーネントを用いて作成しています。
ログアウトは`handleClick`内で`aws-amplify`の`Auth.signOut()`することで実現しています。
また、後述するRecoilのatomにログインユーザー名を格納しています。

```tsx:App.tsx
import React, { useState } from "react";
import Amplify, { Auth } from "aws-amplify";
import { AmplifyAuthenticator, AmplifySignUp } from "@aws-amplify/ui-react";
import {
  AuthState,
  onAuthUIStateChange,
  CognitoUserInterface,
} from "@aws-amplify/ui-components";
import awsconfig from "./aws-exports";
import { RecoilRoot } from "recoil";
import { createStyles, Theme, makeStyles } from "@material-ui/core/styles";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import ExitToAppIcon from "@material-ui/icons/ExitToApp";

Amplify.configure(awsconfig);

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    appBar: {
      zIndex: theme.zIndex.drawer + 1,
    },
    toolBar: {
      display: "flex",
    },
    signOut: {
      marginLeft: "auto",
      display: "flex",
    },
  })
);

const App = () => {
  const classes = useStyles();
  const [authState, setAuthState] = useState<AuthState>();
  const [user, setUser] = useState<CognitoUserInterface | undefined>();

  React.useEffect(() => {
    return onAuthUIStateChange((nextAuthState, authData) => {
      setAuthState(nextAuthState as AuthState);
      setUser(authData as CognitoUserInterface);
    });
  }, []);

  const handleClick = () => {
    Auth.signOut();
  };

  return authState === AuthState.SignedIn && user ? (
    <div>
      <RecoilRoot>
        <AppBar className={classes.appBar}>
          <Toolbar className={classes.toolBar}>
            <Typography variant="h6" noWrap>
              ChatApp
            </Typography>
            <div onClick={handleClick} className={classes.signOut}>
              <IconButton
                aria-label="display more actions"
                edge="end"
                color="inherit"
              >
                <ExitToAppIcon />
              </IconButton>
            </div>
          </Toolbar>
        </AppBar>
      </RecoilRoot>
    </div>
  ) : (
    <AmplifyAuthenticator>
      <AmplifySignUp
        slot="sign-up"
        formFields={[
          { type: "username" },
          { type: "password" },
          { type: "email" },
        ]}
      />
    </AmplifyAuthenticator>
  );
};

export default App;
```

### Recoilの実装

`src/recoil/ChatState.tsx`を作成し、下記のように書きます。
1件の投稿を意味する`postState`と投稿リストを意味する`postListState`を作成します。
また、投稿のメッセージだけをget/setするために`messageState`を作成しています。

```tsx:ChatState.tsx
import { atom, selector, DefaultValue, RecoilState } from "recoil";
import produce from "immer";

export interface PostState {
  id: string;
  message: string;
  owner: string;
  user: string;
  createdAt: string;
}

const defaultValue: PostState = {
  id: "",
  message: "",
  owner: "",
  user: "",
  createdAt: "",
};

const atomKeyName: string = "postState";

export const postState = atom({
  key: atomKeyName,
  default: defaultValue,
});

export const messageState: RecoilState<string> = (() => {
  const propName: keyof PostState = "message";
  return selector<string>({
    key: atomKeyName + "/" + propName,
    get: ({ get }) => {
      return get(postState)[propName];
    },
    set: ({ set, get }, newValue) => {
      const tempValue: string =
        newValue instanceof DefaultValue ? defaultValue[propName] : newValue;
      const imValue = produce<PostState>(get(postState), (draft) => {
        draft[propName] = tempValue;
      });
      set(postState, imValue);
    },
  });
})();

const postListDefaultValue: PostState[] = [];

export const postListState = atom({
  key: "postListState",
  default: postListDefaultValue,
});

```

以上で認証機能の実装は完了です。
下記の手順で動作確認してみましょう。

1. yarn start
1. ブラウザで http://localhost:3000 にアクセスする
2. Create acccountをクリックする
3. Username、Password、Emailを入力し、CREATE ACCOUNTをクリックする
4. 入力したメールアドレスに送付されたConfirmation Codeを入力し、CONFIRMをクリックする
5. Username、Passwordを入力しログインする  
6. ヘッダーにChatAppと表示されればログイン成功です
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123126/82c46017-421f-9e54-5b7d-09315e82131c.png)

# チャットの実装
続いて、チャットの実装をしていきます。

## バックエンド/インフラ

### GraphQL APIの作成
`amplify add api`でGraphQL APIを作成します。

```
$ amplify add api
? Please select from one of the below mentioned services: GraphQL
? Provide API name: chat
? Choose the default authorization type for the API Amazon Cognito User Pool
? Do you want to configure advanced settings for the GraphQL API No, I am done.
? Do you have an annotated GraphQL schema? No
? Choose a schema template: Single object with fields (e.g., “Todo” with ID, name, description)
? Do you want to edit the schema now? No
```

### GraphQLのスキーマを編集
`amplify/backend/api/chat/build/schema.graphql`に生成されたスキーマを編集します。
すべての投稿を作成日順に取得するために`@key`を使用しています。

```
type Post
  @model
  @key(
    name: "SortByCreatedAt"
    fields: ["owner", "createdAt"]
    queryField: "listPostsSortedByCreatedAt"
  ) {
  id: ID!
  message: String!
  owner: String
  user: String
  createdAt: AWSDateTime
}
```
`@model`、`@key`の説明は公式ドキュメントをご確認ください。
https://docs.amplify.aws/cli/graphql-transformer/model
https://docs.amplify.aws/cli/graphql-transformer/key

### GraphQL APIのデプロイ
`amplify push`でクラウドにGraphQL APIをデプロイします。

```
$ 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
GraphQL endpoint: https://xxxxxxxxx.appsync-api.us-east-1.amazonaws.com/graphql
```

最後の行のURLがApp Syncが提供するGraphQL APIのURLになります。
このURLは自動生成される`src/aws-exports.js`というファイルに自動で書き込まれています。

## フロントエンド

### コンポーネントの実装
チャット機能をもつ`Content`コンポーネントを実装します。
`src/Content.tsx`を作成し、下記コードのように書きます。
登録ボタン押下時にGraphQLの`mutations`によりデータをDynamoDBに登録しています。
また、`Content`コンポーネントの初回呼び出し時にGraphQLの`queries`により投稿一覧を取得しています。全投稿を作成日時順に取得するため、`owner`に`chat`という固定の値を入れています。
さらに、`Content`コンポーネントの初回呼び出し時にGraphQLの`subscriptions`を呼び出すことで、新規投稿をsubscribeしています。自分自身の投稿の場合もsubscribeしているので、自分自身の投稿の場合は`setPost`しないようにする必要があります。


```tsx:Content.tsx
import React, { useEffect } from "react";
import { useRecoilState } from "recoil";
import { postListState, messageState, PostState } from "./recoil/ChatState";
import { API, graphqlOperation } from "aws-amplify";
import { GraphQLResult } from "@aws-amplify/api";
import { listPostsSortedByCreatedAt } from "./graphql/queries";
import { createPost } from "./graphql/mutations";
import { onCreatePost } from "./graphql/subscriptions";
import List from "@material-ui/core/List";
import ListItem from "@material-ui/core/ListItem";
import Chip from "@material-ui/core/Chip";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import { createStyles, Theme, makeStyles } from "@material-ui/core/styles";
import Container from "@material-ui/core/Container";
import { CreatePostMutation, ListPostsSortedByCreatedAtQuery } from "./API";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    container: {
      paddingTop: theme.spacing(10),
      paddingBottom: theme.spacing(10),
      backgroundColor: "white",
    },
    input: {
      display: "flex",
    },
    myMessage: {
      display: "flex",
      justifyContent: "flex-start",
    },
    otherMessage: {
      display: "flex",
      justifyContent: "flex-end",
    },
  })
);

interface ContentProps {
  userName?: string;
}

const Content = (props: ContentProps) => {
  const classes = useStyles();
  const [posts, setPosts] = useRecoilState(postListState);
  const [message, setMessage] = useRecoilState(messageState);

  const handleClick = () => {
    postPost();
  };

  const postPost = async () => {
    const post = (await API.graphql(
      graphqlOperation(createPost, {
        input: { message: message, owner: "chat", user: props.userName },
      })
    )) as GraphQLResult<CreatePostMutation>;
    const postData = post.data?.createPost as PostState;
    setPosts([...posts, postData]);
    setMessage("");
  };

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setMessage(event.target.value);
  };

  useEffect(() => {
    async function getPosts() {
      const res = (await API.graphql(
        graphqlOperation(listPostsSortedByCreatedAt, { owner: "chat" })
      )) as GraphQLResult<ListPostsSortedByCreatedAtQuery>;
      const postData = res?.data?.listPostsSortedByCreatedAt
        ?.items as PostState[];
      setPosts(postData);
    }
    getPosts();
  }, [setPosts]);

  useEffect(() => {
    // @ts-ignore
    const subscription = API.graphql(graphqlOperation(onCreatePost)).subscribe({
      next: (eventData: any) => {
        const post = eventData.value.data.onCreatePost;
        if (post !== undefined && post.user !== props.userName) {
          setPosts([...posts, post]);
        }
      },
    });
    return () => subscription.unsubscribe();
  }, [posts]);

  const postList: JSX.Element[] = [];

  for (const post of posts) {
    if (post.user === props.userName) {
      postList.push(
        <ListItem key={post.id} className={classes.myMessage}>
          <Chip label={post.message}></Chip>
        </ListItem>
      );
    } else {
      postList.push(
        <ListItem key={post.id} className={classes.otherMessage}>
          <Chip label={post.message}></Chip>
        </ListItem>
      );
    }
  }

  return (
    <Container maxWidth="lg" className={classes.container}>
      <div className={classes.input}>
        <TextField value={message} onChange={handleChange} />
        <Button variant="contained" color="secondary" onClick={handleClick}>
          登録する
        </Button>
      </div>
      <List>{postList}</List>
    </Container>
  );
};

export default Content;

```

作成した`Content`コンポーネントを`App`コンポーネントから呼び出します。

```tsx:App.tsx
import React, { useState } from "react";
import Amplify, { Auth } from "aws-amplify";
import { AmplifyAuthenticator, AmplifySignUp } from "@aws-amplify/ui-react";
import {
  AuthState,
  onAuthUIStateChange,
  CognitoUserInterface,
} from "@aws-amplify/ui-components";
import awsconfig from "./aws-exports";
import Content from "./Content";
import { RecoilRoot } from "recoil";
import { createStyles, Theme, makeStyles } from "@material-ui/core/styles";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import ExitToAppIcon from "@material-ui/icons/ExitToApp";

Amplify.configure(awsconfig);

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    appBar: {
      zIndex: theme.zIndex.drawer + 1,
    },
    toolBar: {
      display: "flex",
    },
    signOut: {
      marginLeft: "auto",
      display: "flex",
    },
  })
);

const App = () => {
  const classes = useStyles();
  const [authState, setAuthState] = useState<AuthState>();
  const [user, setUser] = useState<CognitoUserInterface | undefined>();

  React.useEffect(() => {
    return onAuthUIStateChange((nextAuthState, authData) => {
      setAuthState(nextAuthState as AuthState);
      setUser(authData as CognitoUserInterface);
    });
  }, []);

  const handleClick = () => {
    Auth.signOut();
  };

  return authState === AuthState.SignedIn && user ? (
    <div>
      <RecoilRoot>
        <AppBar className={classes.appBar}>
          <Toolbar className={classes.toolBar}>
            <Typography variant="h6" noWrap>
              ChatApp
            </Typography>
            <div onClick={handleClick} className={classes.signOut}>
              <IconButton
                aria-label="display more actions"
                edge="end"
                color="inherit"
              >
                <ExitToAppIcon />
              </IconButton>
            </div>
          </Toolbar>
        </AppBar>
        <Content userName={user.username} />
      </RecoilRoot>
    </div>
  ) : (
    <AmplifyAuthenticator>
      <AmplifySignUp
        slot="sign-up"
        formFields={[
          { type: "username" },
          { type: "password" },
          { type: "email" },
        ]}
      />
    </AmplifyAuthenticator>
  );
};

export default App;

```

以上でチャット機能の実装は完了です。
下記手順で動作確認してみましょう。

1. yarn start
1. ログイン
2. 投稿内容をテキストボックスに入力
3. 投稿する ボタンを押す
4. 投稿内容がリスト表示されていることを確認
5. 別のユーザーでログインする
2. 投稿内容をテキストボックスに入力
3. 投稿する ボタンを押す
4. 自分の投稿内容が左側に、別のユーザーの投稿が右側に表示されていれば成功
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123126/4bfcb75d-5e14-ad30-3da2-42daa102fc54.png)


# フロントエンドをホスティング

最後に、作成したフロントエンドのコードをAmplify Console Static Web Site Hostingでホスティングしましょう。

```
$ amplify hosting add
? Select the plugin module to execute Hosting with Amplify Console (Managed hosting with custom domains, Continuous deployment)
? Choose a type Manual deployment

$ amplify publish
? Are you sure you want to continue? Yes
```
コンソールに出力されたURLにアクセスできれば成功です。


以上で、本記事で作成する認証機能つきチャットアプリの作成は完了です。

# 最後に
作成した環境を削除するには下記を実行してください。

```
$ amplify delete
? Are you sure you want to continue? This CANNOT be undone. (This will delete all the environments of the project from the cloud and wipe out all the local files created by Amplify CLI) Yes
```

本記事で作成した認証機能つきチャットアプリの全体のソースコードは下記で公開しています。
https://github.com/shimi7o/chat