25
22

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 × TypeScript × AmplifyでちょっとしたSNS風アプリを作ってみる

Posted at

概要

前々からAWS Amplifyが気になっており、GWにまとまった時間が取れたので実践してみたところ、驚くほど簡単かつ素早く開発できたのでメモ書き。

今回はちょっとしたSNS風っぽいアプリを作ってみました。

完成イメージ

マイ-ムービー(3).gif

何をもってSNSアプリとするかは意見が分かれるかもしれませんが、ここではとりあえず

  • 認証機能(サインアップ、サインイン、サインアウト)
  • 投稿機能(Post、Comment)
  • 画像アップロード機能

といったどこにでもあるような簡易的な機能を実装していきます。

これらがあればユーザー間で最低限のコミニュケーションは取れるであろうという想定のもとです。

使用技術

Untitled Diagram (1).png

  • React
  • TypeScript
  • AWS Amplify

なお、Amplifyの設定などについては「Amplify CLI」というツールを使います。インストール方法や初期設定についてはググれば他にたくさん記事が出てくるのでそちらを参照していただければと思います。

参照記事: Amplify CLI をインストールしてユーザーを設定する手順

※本記事ではすでにAmplify CLIのインストール初期設定が済んでいる前提で話を進めます。

実装

Amplify CLIの準備ができたらアプリケーション側の実装に入ります。

Reactアプリを作成

何はともあれまずは「create-react-app」コマンドでReactアプリを作成。

$ npx create-react-app react-amplify-sns --template typescript
$ cd react-amplify-sns

不要なファイルを削除

この辺についてはお好みですが、デフォルトで生成されたファイルの中にはこの先使う事の無いものがいくつか含まれているので削除しておきます。

$ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts

↑に伴い、以下の2ファイルを変更してください。

./src/index.tsx
import React from "react"
import ReactDOM from "react-dom"
import "./index.css"
import App from "./App"

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
)
./src/App.tsx
import React from "react"

const App: React.FC = () => {
  return (
    <h1>Hello World!</h1>
  )
}

export default App

ここで一旦、動作確認してみましょう。

$ yarn start

スクリーンショット 2021-05-08 23.44.23.png

localhost:3000 にアクセスして「Hello World!」と返ってくればOKです。

Amplifyを導入

ここからAmplify CLIの出番です。

$ amplify init

「amplify init」コマンドを実行すると初期設定のために対話形式で色々な質問をされるので回答していきます。

? Enter a name for the project reactamplifysns // プロジェクト名を入力(任意)
? Enter a name for the environment dev // 環境名を入力(今回は「dev」)
? Choose your default editor: (Use arrow keys)
❯ Visual Studio Code 
  Android Studio 
  Xcode (Mac OS only) 
  Atom Editor 
  Sublime Text 
  IntelliJ IDEA 
  Vim (via Terminal, Mac OS only)

// エディタを指定(任意)
? Choose the type of app that you're building (Use arrow keys)
  android 
  flutter 
  ios 
❯ javascript 

// どんなタイプのアプリケーションか選択(今回は「javascript」)
? What javascript framework are you using (Use arrow keys)
  angular 
  ember 
  ionic 
❯ react 
  react-native 
  vue 
  none 

// どのフレームワークを使うか(今回は「react」)
? Source Directory Path:  src
? Distribution Directory Path: build
? Build Command:  npm run-script build
? Start Command: npm run-script start

// この辺は全てデフォルトのまま入力
? Select the authentication method you want to use: (Use arrow keys)
❯ AWS profile 
  AWS access keys

// プロファイル(「.aws/config」「.aws/credentials」に記載されているユーザー情報を使うか、アクセスキー・シークレットアクセスキーを直接打ち込んで使うか、どちらかを選択。(今回はプロファイル)

参照記事: AWS プロファイルの設定方法

すると初期化が始まるので、終わるまで待ちましょう。(数分かかる場合もあり)

Adding backend environment dev to AWS Amplify Console app: *********
⠙ Initializing project in the cloud...

...省略...

✔ Successfully created initial AWS cloud resources for deployments.
✔ Initialized provider successfully.
Initialized your environment successfully.

Your project has been successfully initialized and connected to the cloud!

Some next steps:
"amplify status" will show you what you've added already and if it's locally configured or deployed
"amplify add <category>" will allow you to add features like user login or a backend API
"amplify push" will build all your local backend resources and provision it in the cloud
"amplify console" to open the Amplify Console and view your project status
"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud

Pro tip:
Try "amplify add api" to create a backend API and then "amplify publish" to deploy everything

こんな感じで「Successfully」と返ってくれば成功です。

react-amplify-sns
├── amplify
│   ├── backend
│   │   ├── amplify-meta.json
│   │   ├── backend-config.json
│   │   └── tags.json
│   ├── cli.json
│   ├── README.md
│   └── team-provider-info.json
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.tsx
│   ├── aws-exports.js
│   ├── index.css
│   ├── index.tsx
│   └── react-app-env.d.ts
├── .gitignore
├── package.json
├── README.md
├── tsconfig.json
└── yarn.lock

ルートディレクトリ直下に「amplify」、srcディレクトリ内に「aws-exports.js」がそれぞれ自動生成されている事を確認してください。

./src/aws-exports.js
/* eslint-disable */
// WARNING: DO NOT EDIT. This file is automatically generated by AWS Amplify. It will be overwritten.

const awsmobile = {
    "aws_project_region": "ap-northeast-1"
}


export default awsmobile

なお、「aws-exports.js」にはAWSの各リソースを使用するための重要な情報(ユーザープールID、GraphQLエンドポイント、S3バケット名など)が随時書き込まれていくため、取り扱いには注意しましょう。

うっかりGitHubなどにアップしてしまうと面倒な事になります。

認証機能を作成

Amplifyの導入が終わったので、まずは認証機能(サインアップ・サインイン・サインアウト)を作成していきましょう。

$ amplify add auth

参照記事: Authentication with Amplify

初期化の際と同様、「amplify add auth」を実行すると設定のための質問をいくつかされるのでそれぞれ回答していきます。

Using service: Cognito, provided by: awscloudformation
 
 The current configured provider is Amazon Cognito. 
 
 Do you want to use the default authentication and security configuration? 
❯ Default configuration
  Default configuration with Social Provider (Federation) 
  Manual configuration 
  I want to learn more.

// とりあえず「Default configuration」でOK 
Warning: you will not be able to edit these selections. 
 How do you want users to be able to sign in? (Use arrow keys)
❯ Username 
  Email 
  Phone Number 
  Email or Phone Number 
  I want to learn more. 

// サインイン(時に使用する値を選択(今回は「Username」)
Do you want to configure advanced settings? (Use arrow keys)
❯ No, I am done. 
  Yes, I want to make some additional changes. 

// より詳細な設定を行うかどうか(今回はもうこれで十分なので「No」)

ここまで入力すると、認証用の各AWSリソースを作成するためのファイルが自動で生成され始めます。

Successfully added auth resource reactamplifysns******** locally

Some next steps:
"amplify push" will build all your local backend resources and provision it in the cloud
"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud

準備が完了したら、「amplify push」コマンドで適用。

✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

| Category | Resource name           | Operation | Provider plugin   |
| -------- | ----------------------- | --------- | ----------------- |
| Auth     | reactamplifysns******** | Create    | awscloudformation |
? Are you sure you want to continue? (Y/n)

...省略...

⠼ Updating resources in the cloud. This may take a few minutes...⠋ Uploading fil✔
All resources are updated in the cloud

大体2〜3分でリソースの作成が終わると思います。

スクリーンショット 2021-05-09 1.17.25_censored.jpg

AWSコンソール画面から「Cognito」へと進み、ユーザープールが作成されていればOK。

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

ここからはReactアプリ側の実装に入ります。上記コマンドを実行してAmplify用のライブラリをインストールした後、「./src/App.tsx」を次のように編集します。

./src/App.tsx
import React from "react"

import Amplify from "aws-amplify"
import { AmplifyAuthenticator, AmplifySignUp, AmplifySignOut } from "@aws-amplify/ui-react"
import awsconfig from "./aws-exports"

Amplify.configure(awsconfig)

const App: React.FC = () => {
  return (
    <AmplifyAuthenticator>
      <AmplifySignUp
        slot="sign-up"
        formFields={[
          { type: "username" },
          { type: "email" },
          { type: "password" }
        ]}
      >
      </AmplifySignUp>
      <h1>You have successfully signed in</h1>
      <AmplifySignOut />
    </AmplifyAuthenticator>
  )
}

export default App

この状態で localhost:3000 にアクセスすると、良い感じのサインアップ・サインイン画面が表示されるはず。

サインアップ

スクリーンショット 2021-05-09 1.35.28.png

サインイン

スクリーンショット 2021-05-09 1.36.03.png

試しにユーザー作成してみましょう。

スクリーンショット 2021-05-09 1.46.07.png

デフォルトの設定だとサインアップ時に認証コードが発行されるようになっているので、メールアドレス宛に届いたものを入力します。

スクリーンショット 2021-05-09 1.49.12.png

認証に成功するとこんな感じの画面に移行されるでしょう。(逆にサインインするまでこのページへ飛ぶ事はできなくなりました。)

念のため、サインアウトできるかどうかも確認しておいてください。

スクリーンショット 2021-05-09 1.51.21_censored.jpg

Cognitoのユーザープールからもユーザーが作成された事を確認できます。

これでひとまず認証機能の作成は完了です。

Material-UIを導入

一区切りついたので、この辺でUIを整える用のライブラリとして「Material-UI」を導入しておきましょう。

$ yarn add @material-ui/core@next @material-ui/icons@next @material-ui/lab@next @material-ui/styled-engine @emotion/react @emotion/styled react-router-dom @types/react-router-dom

ついこの間、最新バージョンであるv5が発表されたので今回はそちらを試してみます。(ライブラリ名の後に「@next」を付けると最新版がインストールできるみたいです。)

$ mkdir src/components
$ mkdir src/components/layouts
$ touch src/components/layouts/Header.tsx
$ touch src/components/layouts/Wrapper.tsx
./src/components/layouts/Header.tsx
import React, { useContext } from "react"
import { Link } from "react-router-dom"

import { makeStyles, Theme } 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 Button from "@material-ui/core/Button"
import IconButton from "@material-ui/core/IconButton"
import MenuIcon from "@material-ui/icons/Menu"

import { Auth } from "aws-amplify"

import { UserContext } from "../../App"

const useStyles = makeStyles((theme: Theme) => ({
  iconButton: {
    marginRight: theme.spacing(2),
  },
  title: {
    flexGrow: 1,
    textDecoration: "none",
    color: "inherit"
  }
}))

const Header: React.FC = () => {
  const { setCurrentUser } = useContext(UserContext)

  const classes = useStyles()

  // サインアウトボタンを設置
  const signout = () => {
    Auth.signOut().catch((err: any) => console.log(err))
    setCurrentUser(undefined)
  }

  return (
    <>
      <AppBar position="static">
        <Toolbar>
          <IconButton
            edge="start"
            className={classes.iconButton}
            color="inherit"
          >
            <MenuIcon />
          </IconButton>
          <Typography
            component={Link}
            to="/"
            variant="h6"
            className={classes.title}
          >
            Sample
          </Typography>
          <Button
            onClick={signout}
            color="inherit"
          >
            Sign Out
          </Button>
        </Toolbar>
      </AppBar>
    </>
  )
}

export default Header
./src/components/layouts/Wrapper.tsx
import React from "react"

import { Container, Grid } from "@material-ui/core"
import { makeStyles } from "@material-ui/core/styles"

import Header from "./Header"

const useStyles = makeStyles(() => ({
  container: {
    margin: "3rem 0 4rem"
  }
}))

type WrapperProps = {
  children: React.ReactElement
}

const Wrapper: React.FC<WrapperProps> = ({ children }) => {
  const classes = useStyles()

  return (
    <>
      <header>
        <Header />
      </header>
      <main>
        <Container maxWidth="lg" className={classes.container}>
          <Grid container direction="row" justifyContent="center">
            <Grid item>
              {children}
            </Grid>   
          </Grid>
        </Container>
      </main>
    </>
  )
}

export default Wrapper
./src/App.tsx
import React, { useState, useEffect, createContext } from "react"
import { BrowserRouter as Router } from "react-router-dom"

import Amplify, { Auth } from "aws-amplify"
import { AmplifyAuthenticator, AmplifySignUp } from "@aws-amplify/ui-react"
import {AuthState, onAuthUIStateChange} from "@aws-amplify/ui-components"
import awsconfig from "./aws-exports"

import Wrapper from "./components/layouts/Wrapper"

// Cognitoで作成したユーザー情報
type User = {
  id: string,
  username: string,
  attributes: {
    email: string
    sub: string // いわゆるUID的なもの(一意の識別子)
  }
}

// 認証済みユーザーの情報はグローバルで取り扱いたいのでContextを使用
export const UserContext = createContext({} as {
  userInfo: User | undefined
  setCurrentUser: React.Dispatch<React.SetStateAction<object | undefined>>
})

Amplify.configure(awsconfig)

const App: React.FC = () => {
  const [authState, setAuthState] = useState<AuthState>()
  const [currentUser, setCurrentUser] = useState<object | undefined>()
  const [userInfo, setUserInfo] = useState<User>()

  const getUserInfo = async () => {
    const currentUserInfo = await Auth.currentUserInfo()
    setUserInfo(currentUserInfo)
  }

  useEffect(() => {
    return onAuthUIStateChange((nextAuthState, authData) => {
      setAuthState(nextAuthState)
      setCurrentUser(authData)
    })
  }, [])

  useEffect(() => {
    getUserInfo()
  }, [])

  // 未認証のユーザーはサインインページへ飛ばされるようにする
  return authState === AuthState.SignedIn && currentUser ? (
    <Router>
      <UserContext.Provider value={{ userInfo, setCurrentUser }}>
        <Wrapper>
          <h1>You have successfully signed in</h1>
        </Wrapper>
      </UserContext.Provider>
    </Router>
  ) : (
    <AmplifyAuthenticator>
      <AmplifySignUp
        slot="sign-up"
        formFields={[
          { type: "username" },
          { type: "email" },
          { type: "password" }
        ]}
      >
      </AmplifySignUp>
    </AmplifyAuthenticator>
  )
}

export default App

スクリーンショット 2021-05-09 2.16.53.png

先ほどよりもだいぶ良い感じの見た目になりましたね。

各種API(Post、Comment)とデータベースを作成

ここからはバックエンド部分の作成に入ります。今回は投稿(Post)およびコメント(Comment)の取得・作成・削除ができるようにするところを目標としましょう。

$ amplify add api

参照記事: Create the GraphQL API

例の如く対話形式での質問が始まるのでそれぞれ回答していきます。

? Please select from one of the below mentioned services: (Use arrow keys)
❯ GraphQL 
  REST

// GraphQLとREST、どちらの形式でAPIを作成するか(今回は「GraphQL」)
? Provide API name: reactamplifysns

// API名(任意)
? Choose the default authorization type for the API 
  API key 
❯ Amazon Cognito User Pool 
  IAM 
  OpenID Connect

// APIを使用するユーザーの認証方法(今回は「Amazon Cognito User Pool」)
? Do you want to configure advanced settings for the GraphQL API (Use arrow keys
)
❯ No, I am done. 
  Yes, I want to make some additional changes. 

// デフォルトの設定で十分なので「No」
? Do you have an annotated GraphQL schema? No

// 「No」
? Choose a schema template: 
  Single object with fields (e.g., “Todo” with ID, name, description) 
❯ One-to-many relationship (e.g., “Blogs” with “Posts” and “Comments”) 
  Objects with fine-grained access control (e.g., a project management app with 
owner-based authorization) 

// PostとCommentの関係は1対多なので「One-to-many relationship」を選択
The following types do not have '@auth' enabled. Consider using @auth with @model
	 - Blog
	 - Post
	 - Comment
Learn more about @auth here: https://docs.amplify.aws/cli/graphql-transformer/auth


GraphQL schema compiled successfully.

Edit your schema at /Users/kazama_t/Workspace2/react-amplify-sns/amplify/backend/api/reactamplifysns/schema.graphql or place .graphql files in a directory at /Users/kazama_t/Workspace2/react-amplify-sns/amplify/backend/api/reactamplifysns/schema
? Do you want to edit the schema now? (y/N) N

// 後で編集するので「N」

すると「./amplify/backend/api//schema.graphql」というファイルが自動生成されるので、次のように書き換えてください。

./amplify/backend/api//schema.graphql
type Post
  @model
  @key(
    name: "SortByCreatedAt"
    fields: ["status", "createdAt"]
    queryField: "listPostsSortedByCreatedAt"
  ) {
  id: ID!
  content: String!
  owner: String!
  image: String
  status: PostStatus!
  comments: [Comment] @connection(keyName: "byPost", fields: ["id"])
  createdAt: AWSDateTime
}

enum PostStatus {
  published
  unpublished
}

type Comment
  @model
  @key(
    name: "byPost",
    fields: ["postId", "content"]
  ) {
  id: ID!
  postId: ID!
  content: String!
  owner: String!
  post: Post @connection(fields: ["postId"])
}

@connection」を付けるといわゆるリレーション的なものが張れるみたいです。

参照記事: https://docs.amplify.aws/cli/graphql-transformer/connection

また、どうやらAmplifyで自動されたクエリ(listPosts)だとデータを作成日時の順で取得できないみたいあので、「listPostsSortedByCreatedAt」というクエリを手動で追加しました。

参照記事: Amplify&GraphQLでデータを取得するときにソートするために

$ amplify push

「amplify push」を実行します。

✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

| Category | Resource name           | Operation | Provider plugin   |
| -------- | ----------------------- | --------- | ----------------- |
| Api      | reactamplifysns         | Create    | awscloudformation |
| Auth     | reactamplifysns******** | No Change | awscloudformation |
? Are you sure you want to continue? Yes

// 特に問題無ければ「Yes」
? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target 
  javascript 
❯ typescript 
  flow

// 今回はTypeScriptを使用しているので「typescript」
? Enter the file name pattern of graphql queries, mutations and subscriptions sr
c/graphql/**/*.ts

// デフォルトのまま回答でOK
Do you want to generate/update all possible GraphQL operations - queries, muta
tions and subscriptions Yes

// GraphQlを実行するためのコードを自動生成しても良いかどうか(「Yes」でOK)
? Enter maximum statement depth [increase from default if your schema is deeply 
nested] 2

// ネストの深さ(今回は「2」)
? Enter the file name for the generated code src/types/index.ts

// 自動生成される型定義ファイルの名前(今回は「src/types/index.ts」)

ここまで入力するとAWSリソース(AppSync、DynamoDBなど)の作成が始まるので2〜3分ほど待ちましょう。

⠧ Updating resources in the cloud. This may take a few minutes...

...省略...

✔ Generated GraphQL operations successfully and saved at src/graphql
✔ Code generated successfully and saved in file src/types/index.ts
⠴ Updating resources in the cloud. This may take a few minutes...⠋ Uploading fil✔ All resources are updated in the cloud

GraphQL endpoint: https://************.appsync-api.ap-northeast-1.amazonaws.com/graphql

こんな感じのメッセージが表示されれば成功です。

├── src
│   ├── App.tsx
│   ├── aws-exports.js
│   ├── components
│   │   └── layouts
│   │       ├── Header.tsx
│   │       └── Wrapper.tsx
│   ├── graphql
│   │   ├── mutations.ts
│   │   ├── queries.ts
│   │   ├── schema.json
│   │   └── subscriptions.ts
│   ├── index.css
│   ├── index.tsx
│   ├── react-app-env.d.ts
│   └── types
|       └── index.ts

新たに「graphql」「types」というディレクトリが作られているのも確認してください。

  • mutations.ts
    • 作成・更新・削除系のクエリ
  • queries.ts
    • 取得系のクエリ
  • subscriptions.ts
    • 購読系(データ情報変更の受け取り)のクエリ

基本的には自動生成されたものを使用すれば事足りますが、今回実装する内容においては一部書き足し・修正が必要なのでそれぞれ変更してください。

./src/graphql/queries.ts
// listPostsSortedByCreatedAtを以下のように修正

export const listPostsSortedByCreatedAt = /* GraphQL */ `
  query ListPostsSortedByCreatedAt(
    $status: PostStatus
    $createdAt: ModelStringKeyConditionInput
    $sortDirection: ModelSortDirection
    $filter: ModelPostFilterInput
    $limit: Int
    $nextToken: String
  ) {
    listPostsSortedByCreatedAt(
      status: $status
      createdAt: $createdAt
      sortDirection: $sortDirection
      filter: $filter
      limit: $limit
      nextToken: $nextToken
    ) {
      items {
        id
        content
        owner
        image
        status
        comments {
          items {
            id
            postId
            content
            owner
          }
          nextToken
        }
        likes {
          items {
            id
            postId
            owner
          }
          nextToken
        }
        createdAt
        updatedAt
      }
      nextToken
    }
  }
`;
./src/types/index.ts
// Postの型定義を以下のように変更

export type Post = {
  __typename: "Post",
  id?: string,
  content?: string,
  owner?: string,
  image?: string | null,
  status?: PostStatus,
  comments?: ModelCommentConnection | null, // nullを許容
  createdAt?: string | null,
  updatedAt?: string
}

// Commentの型定義を以下のように変更

export type Comment = {
  __typename: "Comment",
  id?: string,
  postId?: string,
  content?: string,
  owner?: string,
  post?: Post | null, // nullを許容
  createdAt?: string,
  updatedAt?: string,
}

// CreatePostInputの型定義を以下のように変更

export type CreatePostInput = {
  id?: string | null,
  content: string,
  owner: string | undefined, // undefinedを許容
  image?: string | null,
  status: PostStatus,
  createdAt?: string | null,
}

// CreateCommentInputの型定義を以下のように変更

export type CreateCommentInput = {
  id?: string | null,
  postId: string | undefined, // undefinedを許容
  content: string,
  owner: string | undefined, // undefinedを許容
}

// ...省略...

// 以下の型定義を追記

export type User = {
  id: string,
  username: string,
  attributes: {
    email: string
    sub: string
  }
}

export type OnCreatePostSubscriptionData = {
  value: {
    data: OnCreatePostSubscription
  }
}

export type OnDeletePostSubscriptionData = {
  value: {
    data: OnDeletePostSubscription
  }
}

export type OnCreateCommentSubscriptionData = {
  value: {
    data: OnCreateCommentSubscription
  }
}

export type OnDeleteCommentSubscriptionData = {
  value: {
    data: OnDeleteCommentSubscription
  }
}

一通りの準備ができたので、投稿(Post)とコメント(Comment)ができるように実装していきます。

$ mkdir src/components/post
$ touch src/components/post/PostList.tsx
$ touch src/components/post/PostItem.tsx
$ touch src/components/post/PostForm.tsx
$ touch src/components/post/CommentItem.tsx
$ touch src/components/post/CommentForm.tsx
./src/components/post/PostList.tsx
import React, { useEffect, useState } from "react"

import API, { graphqlOperation } from "@aws-amplify/api"

import { makeStyles, Theme } from "@material-ui/core/styles"
import Box from "@material-ui/core/Box"
import Button from "@material-ui/core/Button"
import RotateRightIcon from "@material-ui/icons/RotateRight"
import AutorenewIcon from "@material-ui/icons/Autorenew"

import PostForm from "./PostForm"
import PostItem from "./PostItem"

import { listPostsSortedByCreatedAt } from "../../graphql/queries"
import { onCreatePost, onDeletePost } from "../../graphql/subscriptions"

import { 
  Post,
  ListPostsSortedByCreatedAtQuery,
  OnCreatePostSubscriptionData,
  OnDeletePostSubscriptionData
} from "../../types/index"

const useStyles = makeStyles((theme: Theme) => ({
  box: {
    marginTop: "2rem",
    width: 320
  }
}))

const PostList: React.FC = () => {
  const classes = useStyles()

  const [loading, setLoading] = useState<boolean>(false)
  const [posts, setPosts] = useState<Post[]>([])
  const [nextToken, setNextToken] = useState<string | null | undefined>(null)

  const getPosts = async () => {
    const result = await API.graphql({
      query: listPostsSortedByCreatedAt,
      variables: {
        status: "published",
        sortDirection: "DESC",
        limit: 5, // 一度のリクエストで取得可能な件数(この辺はお好みで)
        nextToken: nextToken
      }
    })

    if ("data" in result && result.data) {
      const data = result.data as ListPostsSortedByCreatedAtQuery
      if (data.listPostsSortedByCreatedAt) {
        setPosts(data.listPostsSortedByCreatedAt.items as Post[])
        setNextToken(data.listPostsSortedByCreatedAt.nextToken)
      }
    }
  }

  // 追加で投稿(Post)を取得するための関数(ページネーション)
  const loadMore = async () => {
    setLoading(true)

    const result = await API.graphql({
      query: listPostsSortedByCreatedAt,
      variables: {
        status: "published",
        sortDirection: "DESC",
        limit: 5,
        nextToken: nextToken
      }
    })

    if ("data" in result && result.data) {
      const data = result.data as ListPostsSortedByCreatedAtQuery
      if (data.listPostsSortedByCreatedAt) {
        const items = data.listPostsSortedByCreatedAt.items as Post[]
        setPosts((prev) => [...prev, ...items])
        setNextToken(data.listPostsSortedByCreatedAt.nextToken)
      }
    }

    setLoading(false)
  }

  // subscribe = データ変更情報をリアルタイムで取得・反映
  const subscribeCreatedPost = () => {
    const client = API.graphql(graphqlOperation(onCreatePost))

    if ("subscribe" in client) {
      client.subscribe({
        next: ({ value: { data } }: OnCreatePostSubscriptionData) => {
          if (data.onCreatePost) {
            const createdPost: Post = data.onCreatePost
            setPosts((prev) => [createdPost, ...prev])
          }
        }
      })
    }
  }

  const subscribeDeletedPost = () => {
    const client = API.graphql(graphqlOperation(onDeletePost))
    
    if ("subscribe" in client) {
      client.subscribe({
        next: ({ value: { data } }: OnDeletePostSubscriptionData) => {
          if (data.onDeletePost) {
            const deletedPost: Post = data.onDeletePost
            setPosts((prev) => prev.filter(post => post.id !== deletedPost.id))
          }
        }
      })
    }
  }

  useEffect(() => {
    getPosts()
    subscribeCreatedPost()
    subscribeDeletedPost()
  }, [])

  return (
    <>
      <PostForm />
      { posts?.map((post: Post) => {
        return (
          <PostItem
            key={post.id}
            post={post}
          />
        )}
      )}
      { nextToken !== null ?
        <Box
          className={classes.box}
          textAlign="center"
        >
          <Button
            variant="outlined"
            color="primary"
            size="small"
            startIcon={loading ? <RotateRightIcon /> : <AutorenewIcon />}
            onClick={loadMore}
          >
            { loading ? "Now loading..." : "Load More..." }
          </Button>
        </Box> : null
      }
    </>
  )
}

export default PostList
  • nextToken: ページネーションのために利用する値

参照記事: GraphQL pagination

この値がnullでない限りは次のページがあるという判断になるみたいです。今回はそれを利用してページネーション的な機能を実装しました。

マイ-ムービー(4).gif

./src/components/post/PostItem.tsx
import React, { useState, useEffect, useContext } from "react"

import { makeStyles, Theme } from "@material-ui/core/styles"
import clsx from "clsx"
import Card from "@material-ui/core/Card"
import CardHeader from "@material-ui/core/CardHeader"
import CardMedia from "@material-ui/core/CardMedia"
import CardContent from "@material-ui/core/CardContent"
import CardActions from "@material-ui/core/CardActions"
import Avatar from "@material-ui/core/Avatar"
import IconButton from "@material-ui/core/IconButton"
import Typography from "@material-ui/core/Typography"
import FavoriteBorderIcon from "@material-ui/icons/FavoriteBorder"
import FavoriteIcon from "@material-ui/icons/Favorite"
import ShareIcon from "@material-ui/icons/Share"
import DeleteOutlineOutlinedIcon from "@material-ui/icons/DeleteOutlineOutlined"
import ExpandMoreIcon from "@material-ui/icons/ExpandMore"
import Collapse from "@material-ui/core/Collapse"

import API, { graphqlOperation } from "@aws-amplify/api"
import { deletePost } from "../../graphql/mutations"

import { UserContext } from "../../App"
import {
  Post,
  Comment,
  OnCreateCommentSubscriptionData,
  OnDeleteCommentSubscriptionData
} from "../../types/index"

import { onCreateComment, onDeleteComment } from "../../graphql/subscriptions"

import CommentForm from "./CommentForm"
import CommentItem from "./CommentItem"

const useStyles = makeStyles((theme: Theme) => ({
  card: {
    width: 320,
    marginTop: "2rem",
    transition: "all 0.3s",
    "&:hover": {
      boxShadow:
        "1px 0px 20px -1px rgba(0,0,0,0.2), 0px 0px 20px 5px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12)",
      transform: "translateY(-3px)"
    }
  },
  deleteBtn: {
    marginLeft: "auto"
  },
  expandBtn: {
    marginLeft: "auto"
  },
  expand: {
    transform: "rotate(0deg)",
    marginLeft: "auto",
    transition: theme.transitions.create("transform", {
      duration: theme.transitions.duration.shortest
    })
  },
  expandOpen: {
    transform: "rotate(180deg)"
  }
}))

type PostItemProps = {
  post: Post
}

const PostItem: React.FC<PostItemProps> = ({ post }) => {
  const { userInfo } = useContext(UserContext)

  const classes = useStyles()

  const [likes, setLikes] = useState<boolean>(false)
  const [comments, setComments] = useState<(Comment | null)[] | null | undefined>([])
  const [expanded, setExpanded] = useState(false)

  const handleDeletePost = async (id: string | undefined) => {
    if (!id) return

    try {
      await API.graphql(graphqlOperation(deletePost, { input: { id: id } }))
    } catch (err: any) {
      console.log(err)
    }
  }

  const getComments = () => {
    setComments(post.comments?.items)
  }

  const subscribeCreatedComment = () => {
    const client = API.graphql(graphqlOperation(onCreateComment))

    if ("subscribe" in client) {
      client.subscribe({
        next: ({ value: { data } }: OnCreateCommentSubscriptionData) => {
          if (data.onCreateComment) {
            const createdComment: Comment = data.onCreateComment
            setComments((prev) => prev?.length? [createdComment, ...prev] : [createdComment, ...[]])
          }
        }
      })
    }
  }

  const subscribeDeletedComment = () => {
    const client = API.graphql(graphqlOperation(onDeleteComment))

    if ("subscribe" in client) {
      client.subscribe({
        next: ({ value: { data } }: OnDeleteCommentSubscriptionData) => {
          if (data.onDeleteComment) {
            const deletedComment: Comment = data.onDeleteComment
            setComments((prev) => prev?.filter((comment) => comment?.id !== deletedComment.id))
          }
        }
      })
    }
  }

  useEffect(() => {
    getComments()
    subscribeCreatedComment()
    subscribeDeletedComment()
  }, [])

  return (
    <>
      <Card className={classes.card}>
        <CardHeader
          avatar={
            <Avatar>
              U
            </Avatar>
          }
          // 投稿主と認証済みユーザーが一致する場合に削除ボタンを表示
          action={post.owner === userInfo?.attributes.sub ?
            <div className={classes.deleteBtn}>
              <IconButton
                onClick={() => handleDeletePost(post.id)}
              >
                <DeleteOutlineOutlinedIcon />
              </IconButton>
            </div> : null
          }
          title={userInfo?.username}
        />
        { post.image ?
          <CardMedia
            component="img"
            src={post.image}
            alt="post-img"
          /> : null
        }
        <CardContent>
          <Typography variant="body2" color="textSecondary" component="span">
            { post.content?.split("\n").map((content: string, index: number) => {
                return (
                  <p key={index}>{content}</p>
                )
              })
            }
          </Typography>
        </CardContent>
        <CardActions disableSpacing>
          {
            likes ?
              <IconButton onClick={() => setLikes(false)}>
                <FavoriteIcon />
              </IconButton>
                :
              <IconButton onClick={(e) => setLikes(true)}>
                <FavoriteBorderIcon />
              </IconButton>
          }
          <IconButton>
            <ShareIcon />
          </IconButton>
          <div className={classes.expandBtn}>
            <IconButton
              className={clsx(classes.expand, {
                [classes.expandOpen]: expanded,
              })}
              onClick={() => setExpanded(!expanded)}
              aria-expanded={expanded}
              aria-label="show more"
            >
              <ExpandMoreIcon />
            </IconButton>
          </div>
        </CardActions>
        <Collapse in={expanded} timeout="auto" unmountOnExit>
          <CardContent>
            <CommentForm
              postId={post?.id}
            />
            { comments?.map((comment) => {
              return (
                <CommentItem
                  key={comment?.id}
                  comment={comment}
                />
              )}
            )}
          </CardContent>
        </Collapse>
      </Card>
    </>
  )
}

export default PostItem
./src/components/post/PostForm.tsx
import React, { useState, useContext } from "react"

import { makeStyles, Theme } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import Button from "@material-ui/core/Button"
import CreateIcon from "@material-ui/icons/Create"

import API, { graphqlOperation } from "@aws-amplify/api"
import { createPost } from "../../graphql/mutations"
import { CreatePostInput, PostStatus } from "../../types/index"

import { UserContext } from "../../App"

const useStyles = makeStyles((theme: Theme) => ({
  form: {
    display: "flex",
    flexWrap: "wrap",
    width: 320
  },
  inputFileBtn: {
    marginTop: "10px"
  },
  submitBtn: {
    marginTop: "10px",
    marginLeft: "auto"
  },
  box: {
    margin: "2rem 0 4rem",
    width: 320
  },
  preview: {
    width: "100%"
  }
}))

const PostForm: React.FC = () => {
  const { userInfo } = useContext(UserContext)

  const classes = useStyles()

  const [content, setContent] = useState<string>("")

  const handleCreatePost = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    
    if (!content) return

    const data: CreatePostInput = {
      content: content,
      owner: userInfo?.attributes.sub,
      status: PostStatus.published
    }

    try {
      await API.graphql(graphqlOperation(createPost, { input: data }))
      setContent("")
    } catch (err: any) {
      console.log(err)
    }
  }

  return (
    <>
      <form className={classes.form} noValidate onSubmit={handleCreatePost}>
        <TextField
          placeholder="Hello World!"
          variant="outlined"
          multiline
          fullWidth
          rows="4"
          value={content}
          onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
            setContent(e.target.value)
          }}
        />
        <div className={classes.submitBtn}>
          <Button
            type="submit"
            variant="contained"
            size="large"
            color="inherit"
            disabled={!content || content.length > 140}
            startIcon={<CreateIcon />}
            className={classes.submitBtn}
          >
            Post
          </Button>
        </div>
      </form>
    </>
  )
}

export default PostForm
./src/components/post/CommentItem.tsx
import React, { useContext } from "react"

import { makeStyles, Theme } from "@material-ui/core/styles"
import Card from "@material-ui/core/Card"
import CardHeader from "@material-ui/core/CardHeader"
import CardContent from "@material-ui/core/CardContent"
import Avatar from "@material-ui/core/Avatar"
import IconButton from "@material-ui/core/IconButton"
import Typography from "@material-ui/core/Typography"
import DeleteOutlineOutlinedIcon from "@material-ui/icons/DeleteOutlineOutlined"

import API, { graphqlOperation } from "@aws-amplify/api"
import { deleteComment } from "../../graphql/mutations"

import { UserContext } from "../../App"
import { Comment } from "../../types/index"

const useStyles = makeStyles((theme: Theme) => ({
  card: {
    width: "100%",
    marginTop: "2rem",
    transition: "all 0.3s",
    "&:hover": {
      boxShadow:
        "1px 0px 20px -1px rgba(0,0,0,0.2), 0px 0px 20px 5px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12)",
      transform: "translateY(-3px)"
    }
  },
  delete: {
    marginLeft: "auto"
  }
}))

type CommentItemProps = {
  comment: Comment | null
}

const CommentItem: React.FC<CommentItemProps> = ({ comment }) => {
  const { userInfo } = useContext(UserContext)

  const classes = useStyles()

  const handleDeleteComment = async (id: string | undefined) => {
    if (!id) return

    try {
      await API.graphql(graphqlOperation(deleteComment, { input: { id: id } }))
    } catch (err: any) {
      console.log(err)
    }
  }

  return (
    <>
      <Card className={classes.card}>
        <CardHeader
          avatar={
            <Avatar>
              U
            </Avatar>
          }
          action={comment?.owner === userInfo?.attributes.sub ?
            <div className={classes.delete}>
              <IconButton
                onClick={() => handleDeleteComment(comment?.id)}
              >
                <DeleteOutlineOutlinedIcon />
              </IconButton>
            </div> : null
          }
          title={userInfo?.username}
        />
        <CardContent>
          <Typography variant="body2" color="textSecondary" component="span">
            { comment?.content?.split("\n").map((content: string, index: number) => {
                return (
                  <p key={index}>{content}</p>
                )
              })
            }
          </Typography>
        </CardContent>
      </Card>
    </>
  )
}

export default CommentItem
./src/components/post/CommentForm.tsx
import React, { useState, useContext } from "react"

import { makeStyles, Theme } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import Button from "@material-ui/core/Button"
import SmsOutlinedIcon from "@material-ui/icons/SmsOutlined"

import API, { graphqlOperation } from "@aws-amplify/api"
import { createComment } from "../../graphql/mutations"
import { CreateCommentInput } from "../../types/index"

import { UserContext } from "../../App"


const useStyles = makeStyles((theme: Theme) => ({
  form: {
    display: "flex",
    flexWrap: "wrap",
    width: "100%"
  },
  submitBtn: {
    marginTop: "10px",
    marginLeft: "auto"
  }
}))

type CommentFormProps = {
  postId: string | undefined
}

const CommentForm: React.FC<CommentFormProps> = ({ postId }) => {
  const { userInfo } = useContext(UserContext)

  const classes = useStyles()

  const [content, setContent] = useState<string>("")

  const handleCreateComment = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    
    if (!content) return

    const data: CreateCommentInput = {
      postId: postId,
      content: content,
      owner: userInfo?.attributes.sub
    }

    try {
      await API.graphql(graphqlOperation(createComment, { input: data }))
      setContent("")
    } catch (err: any) {
      console.log(err)
    }
  }

  return (
    <>
      <form className={classes.form} noValidate onSubmit={handleCreateComment}>
        <TextField
          placeholder="Hello World!"
          variant="outlined"
          multiline
          fullWidth
          rows="4"
          value={content}
          onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
            setContent(e.target.value)
          }}
        />
        <div className={classes.submitBtn}>
          <Button
            type="submit"
            variant="contained"
            size="large"
            color="inherit"
            disabled={!content || content.length > 140}
            startIcon={<SmsOutlinedIcon />}
            className={classes.submitBtn}
          >
            Comment
          </Button>
        </div>
      </form>
    </>
  )
}

export default CommentForm
./src/App.tsx
import React, { useState, useEffect, createContext } from "react"
import { BrowserRouter as Router, Switch, Route } from "react-router-dom"

import Amplify, { Auth } from "aws-amplify"
import { AmplifyAuthenticator, AmplifySignUp } from "@aws-amplify/ui-react"
import awsconfig from "./aws-exports"

import Wrapper from "./components/layouts/Wrapper"
import PostList from "./components/post/PostList"

import { User } from "./types/index"

export const UserContext = createContext({} as {
  currentUser: User | undefined
  setCurrentUser: React.Dispatch<React.SetStateAction<User | undefined>>
})

Amplify.configure(awsconfig)

const App: React.FC = () => {
  const [currentUser, setCurrentUser] = useState<User>()

  const getCurrentUser = async () => {
    const currentUserInfo = await Auth.currentUserInfo()
    setCurrentUser(currentUserInfo)
  }

  useEffect(() => {
    getCurrentUser()
  }, [])

  return (
    <AmplifyAuthenticator>
      <AmplifySignUp
        slot="sign-up"
        formFields={[
          { type: "username" },
          { type: "email" },
          { type: "password" }
        ]}
      >
      </AmplifySignUp>
      <Router>
        <UserContext.Provider value={{ currentUser, setCurrentUser }}>
          <Wrapper>
            <Switch>
              <Route exact path="/" component={PostList} />
            </Switch>
          </Wrapper>
        </UserContext.Provider>
      </Router>
    </AmplifyAuthenticator>
  )
}

export default App

FireShot Capture 130 - React App - localhost.png

現段階だとこんな感じになっていればOKです。

  • 投稿(Post)が作成・削除できるかどうか
  • コメント(Comment)が作成・削除できるかどうか

を確認してください。

画像アップロード機能を作成

特に問題無ければ最後に画像アップロード機能を追加していきます。

$ amplify add storage

参照記事: Storage with Amplify

おなじみの対話形式で質問が始まるので回答していってください。

? Please select from one of the below mentioned services: (Use arrow keys)
❯ Content (Images, audio, video, etc.) 
  NoSQL Database

// 画像などをアップロードしたいので「Content」
? Please provide a friendly name for your resource that will be used to label th
is category in the project: reactamplifysnsstorage

// Storageに付けるラベル名(任意)
? Please provide bucket name: reactamplifysnss3

// S3バケット名(任意)
? Who should have access: (Use arrow keys)
❯ Auth users only 
  Auth and guest users 

// 利用できるユーザー(認証済みのユーザーのみに絞りたいので「Auth users only 」)
? What kind of access do you want for Authenticated users? (Press <space> to sel
ect, <a> to toggle all, <i> to invert selection)
❯◉ create/update
 ◉ read
 ◉ delete

// 認証済みのユーザーが実行可能なアクション(「a」キーを押すと全て選択可能なのでそうする)
? Do you want to add a Lambda Trigger for your S3 Bucket? No

// Lambdaトリガーを付与するかどうか(今回はLmabdaを使用しないので「No」)
Successfully added resource reactamplifysnsstorage locally

If a user is part of a user pool group, run "amplify update storage" to enable IAM group policies for CRUD operations
Some next steps:
"amplify push" builds all of your local backend resources and provisions them in the cloud
"amplify publish" builds all of your local backend and front-end resources (if you added hosting category) and provisions them in the cloud

こんな感じのメッセージが返ってくれば準備完了です。

$ amplify push

「amplify push」コマンドを実行します。

✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

| Category | Resource name           | Operation | Provider plugin   |
| -------- | ----------------------- | --------- | ----------------- |
| Storage  | reactamplifysnsstorage  | Create    | awscloudformation |
| Auth     | reactamplifysns******** | No Change | awscloudformation |
| Api      | reactamplifysns         | No Change | awscloudformation |
? Are you sure you want to continue? Yes

...省略...

⠋ Updating resources in the cloud. This may take a few minutes...⠋ Uploading fil✔ All resources are updated in the cloud

こんな感じのメッセージが返ってくればOK。

スクリーンショット 2021-05-09 6.27.14_censored.jpg

S3バケットも新規に作成されていますね。

なお、このままの状態だと外部からのアクセスができないので、「アクセス許可」内のバケットポリシーに以下のJSONを記述してください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::<バケット名>/public/images/*"
        }
    ]
}

スクリーンショット 2021-05-09 6.29.19_censored.jpg

これでひとまず設定完了です。

あとはReactアプリ側のコードを追記していきましょう。

$ yarn add @types/uuid

画像をS3にアップロードする際、ファイル名が被らないようランダムな識別子を付与したいので上記のライブラリをインストールします。

インストールが終わったら、「./src/components/post/PostForm.tsx」を次のように編集してください。

./src/components/post/PostForm.tsx
import React, { useCallback, useState, useContext } from "react"

import { experimentalStyled as styled } from "@material-ui/core/styles"
import { makeStyles, Theme } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import Button from "@material-ui/core/Button"
import Box from "@material-ui/core/Box"
import IconButton from "@material-ui/core/IconButton"
import PhotoCameraIcon from "@material-ui/icons/PhotoCamera"
import CreateIcon from "@material-ui/icons/Create"
import CancelIcon from "@material-ui/icons/Cancel"

import API, { graphqlOperation } from "@aws-amplify/api"
import { createPost } from "../../graphql/mutations"
import { v4 as uuid } from "uuid"
import awsconfig from "../../aws-exports"
import { Storage } from "aws-amplify"
import { CreatePostInput, PostStatus } from "../../types/index"

import { UserContext } from "../../App"

// S3のバケット名などを取得
const {
  aws_user_files_s3_bucket_region: region,
  aws_user_files_s3_bucket: bucket
} = awsconfig

const useStyles = makeStyles((theme: Theme) => ({
  form: {
    display: "flex",
    flexWrap: "wrap",
    width: 320
  },
  inputFileBtn: {
    marginTop: "10px"
  },
  submitBtn: {
    marginTop: "10px",
    marginLeft: "auto"
  },
  box: {
    margin: "2rem 0 4rem",
    width: 320
  },
  preview: {
    width: "100%"
  }
}))

const Input = styled("input")({
  display: "none"
})

const borderStyles = {
  bgcolor: "background.paper",
  border: 1,
}

const PostForm: React.FC = () => {
  const { userInfo } = useContext(UserContext)

  const classes = useStyles()

  const [content, setContent] = useState<string>("")
  const [file, setFile] = useState<File>()
  const [preview, setPreview] = useState<string>("")

  const uploadImage = useCallback((e) => {
    const file = e.target.files[0]
    setFile(file)
  }, [])

  // 画像プレビュー機能
  const previewImage = useCallback((e) => {
    const file = e.target.files[0]
    setPreview(window.URL.createObjectURL(file))
  }, [])

  const handleCreatePost = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    
    if (!content) return

    const data: CreatePostInput = {
      content: content,
      owner: userInfo?.attributes.sub,
      status: PostStatus.published
    }
    
    if (file) {
      const { name: fileName, type: mimeType } = file
      const key: string = `images/${uuid()}_${fileName}`
      // 最終的な保存先
      const imageUrl: string = `https://${bucket}.s3.${region}.amazonaws.com/public/${key}`

      try {
        await Storage.put(key, file, {
          contentType: mimeType
        })

        data.image = imageUrl
        setFile(undefined)
      } catch (err: any) {
        console.log(err)
      }
    }

    try {
      await API.graphql(graphqlOperation(createPost, { input: data }))
      setContent("")
      setPreview("")
    } catch (err: any) {
      console.log(err)
    }
  }

  return (
    <>
      <form className={classes.form} noValidate onSubmit={handleCreatePost}>
        <TextField
          placeholder="Hello World!"
          variant="outlined"
          multiline
          fullWidth
          rows="4"
          value={content}
          onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
            setContent(e.target.value)
          }}
        />
        <div className={classes.inputFileBtn}>
          <label htmlFor="icon-button-file">
            <Input
              accept="image/*"
              id="icon-button-file" 
              type="file"
              onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
                uploadImage(e)
                previewImage(e)
              }}
            />
            <IconButton color="inherit" component="span">
              <PhotoCameraIcon />
            </IconButton>
          </label>
        </div>
        <div className={classes.submitBtn}>
          <Button
            type="submit"
            variant="contained"
            size="large"
            color="inherit"
            disabled={!content || content.length > 140}
            startIcon={<CreateIcon />}
            className={classes.submitBtn}
          >
            Post
          </Button>
        </div>
      </form>
      { preview ?
        <Box
          sx={{ ...borderStyles, borderRadius: 1, borderColor: "grey.400" }}
          className={classes.box}
        >
          <IconButton
            color="inherit"
            onClick={() => setPreview("")}
          >
            <CancelIcon />
          </IconButton>
          <img
            src={preview}
            alt="preview-img"
            className={classes.preview}
          />
        </Box> : null
      }
    </>
  )
}

export default PostForm

マイ-ムービー(3).gif

最終的にこんな感じの挙動になっていれば完成です。

あとがき

以上、多少雑な説明になってしまいましたが、Amplifyを使ってそれっぽいアプリをサクッと作ってみました。

ある程度の流れ(「amplify add ◯◯(追加したい機能)」→「amplify push ◯◯(追加したい機能)」)さえ掴めればだいぶ高速で開発を進める事はできると思うので、今後も色々試してみたいと思います。

25
22
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
25
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?