React+Amplify+AppSync+TypeScript+Recoilで認証機能つきチャットアプリを作る方法を紹介します。
完成するアプリのデモは以下です。
左右の画面に異なるユーザーでログインし、チャットを行っています。
本記事で作成するアプリのアーキテクチャーは下記です。
Amplify Console Static Web Site Hostingでフロンドエンドのコードをホスティングします。
AWS AppsyncでGraphQL APIを提供し、データベースはDynamoDBを使用します。
Amazon Cognitoをユーザー認証に用いています。
バージョン
使用した環境は以下の通りです。
$ 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
続いて、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にログインユーザー名を格納しています。
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
を作成しています。
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,
});
以上で認証機能の実装は完了です。
下記の手順で動作確認してみましょう。
- yarn start
- ブラウザで http://localhost:3000 にアクセスする
- Create acccountをクリックする
- Username、Password、Emailを入力し、CREATE ACCOUNTをクリックする
- 入力したメールアドレスに送付されたConfirmation Codeを入力し、CONFIRMをクリックする
- Username、Passwordを入力しログインする
- ヘッダーにChatAppと表示されればログイン成功です
チャットの実装
続いて、チャットの実装をしていきます。
バックエンド/インフラ
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
しないようにする必要があります。
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
コンポーネントから呼び出します。
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;
以上でチャット機能の実装は完了です。
下記手順で動作確認してみましょう。
- yarn start
- ログイン
- 投稿内容をテキストボックスに入力
- 投稿する ボタンを押す
- 投稿内容がリスト表示されていることを確認
- 別のユーザーでログインする
- 投稿内容をテキストボックスに入力
- 投稿する ボタンを押す
- 自分の投稿内容が左側に、別のユーザーの投稿が右側に表示されていれば成功
フロントエンドをホスティング
最後に、作成したフロントエンドのコードを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