49
74

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 3 years have passed since last update.

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

Last updated at Posted at 2021-03-07

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

完成するアプリのデモは以下です。
左右の画面に異なるユーザーでログインし、チャットを行っています。
ezgif.com-gif-maker.gif

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

image.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が未インストールの場合は、公式ドキュメントを参考にインストールします。

アプリの雛形作成

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

$ npx create-react-app chat --template typescript

yarn startでサンプルアプリが起動すれば成功です。

$ cd chat
$ yarn start

image.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-amplifyAuth.signOut()することで実現しています。
また、後述するRecoilのatomにログインユーザー名を格納しています。

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を作成しています。

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
  2. ブラウザで http://localhost:3000 にアクセスする
  3. Create acccountをクリックする
  4. Username、Password、Emailを入力し、CREATE ACCOUNTをクリックする
  5. 入力したメールアドレスに送付されたConfirmation Codeを入力し、CONFIRMをクリックする
  6. Username、Passwordを入力しログインする
  7. ヘッダーにChatAppと表示されればログイン成功です
    image.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により投稿一覧を取得しています。全投稿を作成日時順に取得するため、ownerchatという固定の値を入れています。
さらに、Contentコンポーネントの初回呼び出し時にGraphQLのsubscriptionsを呼び出すことで、新規投稿をsubscribeしています。自分自身の投稿の場合もsubscribeしているので、自分自身の投稿の場合はsetPostしないようにする必要があります。

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コンポーネントから呼び出します。

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
  2. ログイン
  3. 投稿内容をテキストボックスに入力
  4. 投稿する ボタンを押す
  5. 投稿内容がリスト表示されていることを確認
  6. 別のユーザーでログインする
  7. 投稿内容をテキストボックスに入力
  8. 投稿する ボタンを押す
  9. 自分の投稿内容が左側に、別のユーザーの投稿が右側に表示されていれば成功
    image.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

49
74
3

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
49
74

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?