20210610のReactに関する記事は7件です。

TypeScriptの型付けでクラス(Class)とインスタンス(Instance)においてハマったポイント

クラスから引っ張ってきたTypeScriptの型付まわりを後回しにしてanyを使いまくっていたら後から型付が面倒になったのですがとりあえずこの記事で知見をまとめたいと思い記事にします。 使用したTypeScriptのバージョンは 4.1.3 でおそらくそれ以下のバージョンにも当てはまる内容かと。 Firebaseのクラスでのユースケース 実際に自分がハマった型はFirebaseの処理を入れたクラスでした。 以下のコードに対して順を追って自分の場合の型付けの流れを解説。 firebase.ts import firebaseConfig from './config'; import firebase from 'firebase'; import { UserSettingsType } from 'models/ProfileTypes'; class Firebase { app!: typeof firebase; auth!: firebase.auth.Auth; db!: firebase.firestore.Firestore; functions!: firebase.functions.Functions; storage!: firebase.storage.Storage; constructor(app: typeof firebase) { if (!firebaseInstance) { app.initializeApp(firebaseConfig); this.app = app; this.auth = app.auth(); this.db = app.firestore(); this.functions = app.functions(); this.storage = app.storage(); } } emailRegister = () => {         // 省略 }; login = () => {         // 省略 }; logout = () => {         // 省略 }; // 他の処理省略 } // インスタンス用の変数 let firebaseInstance: InstanceType<typeof Firebase>; // インスタンス化する関数。引数にはpromise処理した後のfirebaseの結果を代入。 const getFirebaseInstance = (app: typeof firebase): Firebase | null => { if (!firebaseInstance && app) { firebaseInstance = new Firebase(app); return firebaseInstance; } else if (firebaseInstance) { return firebaseInstance; } else { return null; } }; export { Firebase }; export default getFirebaseInstance; 上のクラスとインスタンスに関して自分のハマったポイント 特に意識することなくできている人がいると思いますが自分がハマったポイントを紹介しておきます。 ・クラスで宣言した変数に「!」が必要だったところ ・インスタンスに使う型としてInstanceTypeの指定が必要だったところ ・firebaseライブラリの型はPromise処理後のオブジェクトにも適用できたこと(クラスとインスタンスにあまり関係ないけれどfirebaseまわりの型付けはわからなかった) クラスで宣言した変数に「!」が必要だったところ firebase.ts app!: typeof firebase; auth!: firebase.auth.Auth; db!: firebase.firestore.Firestore; functions!: firebase.functions.Functions; storage!: firebase.storage.Storage; 宣言する際に「!」をつけないと以下のように怒られました。 Property 'app' has no initializer and is not definitely assigned in the constructor.ts(2564) このエラーを調べたところ、strictPropertyInitializationのTSオプションによりクラスへの型チェックが厳しくなっていることが原因でした。 クラスで宣言した変数のプロパティは初期化されておらずコンストラクタの中で定義されていないという内容です。 つまり全てのプロパティはコンストラクタの中だけで宣言すれば良いそうみたいでしたが今のままの方が自分的にはわかりやすいコードであるため「!」をつけた方向にしました。 ちなみに「?」でも良いそうですが、「?」をつけると undefined の型候補がついてしまいコードが無駄に増えてしまうと思い「!」にしました。 また tsconfig にて strictPropertyInitialization を false にすれば良いそうですがここ以外でクラスは使っていなかったので firebase.ts のファイルの範囲だけでの許容という形に一応。 インスタンスに使う型としてInstanceTypeの指定が必要だったところ インスタンスの型付けには InstanceType という コンストラクタ関数型 が用意されています。 (公式ドキュメントの InstanceType 参照 ) それを知らずに typeof Class名 で型付けしましたが別ファイルで呼び出したメソッドで型エラーが生じました。 ちなみに InstanceType の hoge の部分には typeof クラス名 を当てはめます。 firebase.ts let firebaseInstance: InstanceType<typeof Firebase>; firebaseライブラリの型はPromise処理後のオブジェクトにも適用できたこと Class Firebase{以下略} の constructor の引数には Promise処理後 のFirebaseに関わる db, auth, functions などをまとめたオブジェクトを格納しています。 それらのオブジェクトにもそのまま firebase にデフォルトで入っている型を当てはめることができました。 ちなみにこのコードを書くまではライブラリに型がデフォルトで入っていることを知らずググってもわからずかなり詰んでいました。 おまけでそのことも解説しておきます。 型がライブラリにデフォルトで入っていることを知らなかった import してきた firebase ライブラリの中に型定義ファイルも含まれています。 しかし独学でTSをずっと触ってきたこともあり、これまでライブラリとは別にライブラリに対しても型ライブラリもインストールすることが当たり前でデフォルトで型ファイルも入っていることを知りませんでした。 例えば firebase であれば @firebase/auth などを別途インストールして型を当てはめていくものという認識でした。 実際、npm のサイトにも @firebase/auth の型ライブラリがありますが非推奨となっています。 「じゃあ代替えの型ライブラリはどこ?」というのがわからず彷徨い続けて any を使うという敗北を味わうこととなりました。 firebase/hogeには型が入っていない もう一つの落とし穴ですが、 const auth = import('firebase/auth'); const database = import('firebase/firestore'); const functions = import('firebase/functions'); const storage = import('firebase/storage'); .... と言った形で各種ライブラリで型を当てはめようとしたのですが上記のライブラリだと型は入っていませんでした。 (firebase/app には入っています) 中を見ると empty-import.d.ts declare namespace empty {} export = empty; このようになっています。ここら辺も知っていれば沼にはまらなかったなあという感想でした。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

第 9 章 Hooks 、関数コンポーネントの合体強化 パーツ メモ

コミュニティによって普及した HOC HOC とはコンポーネント を引数に取り、戻り値としてコンポーネントを返す関数のこと。 render props render props は必 ずしも render という名前の props を使う必要はなくてね。そのコンポーネントが自身のレンダリン グのために使うコンポーネントの props は、何だろうと技術的には render props と呼ぶの HOC より render props のほうが優れていると主張する理由は次のようなもの ・HOC のように props の名前の衝突が起こりづらく、起こったとしてもコードから一目瞭然 ・TypeScript 使用時、props の型合成が必要ない ・どの変数が機能を注入するための props として親コンポーネントに託されたのかがコードから 判別しやすい ・コンポーネントの外で結合させる必要がなく、JSX の中で動的に扱うことができる ついに Hooks が登場する Hooks は公式が新たに React の機能として提供したもの Hooks はコンポーネントにロジックを抱えた別のコンポーネントをかぶせるのではなく、コンポーネント システムの外に状態やロジックを持つ手段を提供した Hooks を使えば、状態を持ったロジックを完全に任意の コンポーネントから切り離し、それ単独でテストしたり、別のコンポーネントで再利用することが 簡単にできるようになる。コンポーネントの階層構造を変えることなしに、状態を伴った再利用可 能なロジックを追加できるというのが画期的だった Hooks がコンポーネントシステムの外に状態やロジックを 持つしくみを提供したことによって、ほぼ関数コンポーネントだけでアプリケーションが作れるよ うになったの。というか Hooks は関数コンポーネント内でしか使えない 2021 年 3 月現在 getSnapshotBeforeUpdate、getDerivedStateFromError、componentDidCatch の 3 つのライフサイクルメソッドに相当する機能は Hooks では提供されてない ただ将来的には 追加される予定 新しく作るコンポーネン トは Hooks と関数コンポーネントで作ることが推奨されている Hooks で state を扱う State Hook といってクラスコンポーネントの state に相当するものを関数コンポーネントでも使えるようにする機能 useState という関数を 使う const [count, setCount] = useState(0); setCount(100); setCount(prevCount => prevCount + 1); useState は戻り値として state 変数とその state 更新関数をタプルとして返す だから上の ように分割代入で受け取る 配列の分割代入と同様なのでもちろん、state 変数とその更新関数 の名前は好きなものに設定できる useState(INITIAL_VALUE) のように引数を渡すと、その値が state 変数の初期値として設定される import { VFC, useState } from 'react'; import { Button, Card, Statistic } from 'semantic-ui-react'; import './Counter.css'; const Counter: VFC = () => { const [count, setCount] = useState(0); const increment = () => setCount((c) => c + 1); const reset = () => setCount(0); return ( <Card> <Statistic className="number-board"> <Statistic.Label>count</Statistic.Label> <Statistic.Value>{count}</Statistic.Value> </Statistic> <Card.Content> <div className="ui two buttons"> <Button color="red" onClick={reset}> Reset </Button> <Button color="green" onClick={increment}> +1 </Button> </div> </Card.Content> </Card> ); }; export default Counter; const plusThreeDirectly = () => [0, 1, 2].forEach((_) => setCount(count + 1)); const plusThreeWithFunction = () => [0, 1, 2].forEach((_) => setCount((c) => c + 1)); state 変数はそのコンポーネントのレンダリングごとで一定 よって plusThreeDirectly() はそ のレンダリング時点での count が 0 だったとしたら、それを 1 に上書きする処理を 3 回繰り返すこ とになる だから state 変数を相対的に変更する処理を行うときは、前の値を直接参照・変更 するのは避けて必ず setCount((c) => c + 1) のように関数で書くべき Hooks の呼び出しはその関数コンポーネントの論理階層の トップレベルでないといけない Hooks で副作用を扱う Effect Hook の使い方 副作用を扱う Hooks API を Effect Hook という コンポーネントの『副作用』とは ネットワークを介したデータの取 得やそのリアクティブな購読、ログの記録、リアル DOM の手動での書き換えといったもの Effect Hook とは、props が同一であってもその関数コンポーネントの出力内容を変えてしまうような処理をレンダリングの タイミングに同期させて実行するための Hooks API のこと useEffect という関数を使う const SampleComponent: VFC = () => { const [data, setData] = useState(null); … useEffect(() => { doSomething(); return () => clearSomething(); }, [someDeps]); … }; まず useEffect は第 1 引数として、引数を持たない関数を受け取る。この関数 の中身が任意のタイミングで実行される useEffect へ第 1 引数として渡す関数がその戻り値として任意の関数を返すように しておくと、そのコンポーネントがアンマウントされるときにその戻り値の関数を実行してくれる useEffect の第 2 引数、ここには変数の配列を渡せるようになってる この配列の中 に格納された変数がひとつでも前のレンダリング時と比較して差分があったときだけ、第 1 引数の 関数が実行される この第 2 引数のことを 依存配列(dependencies array)ともいう 第 2 引数は省略可能 ただし依存配列が渡されなかった場合、レンダリングごとに毎回第 1 引数 の関数が実行されることになる 空配列 [] を渡すと、初回のレンダリング時にのみ第 1 引数の関数が実行される import { VFC, useEffect, useState } from 'react'; import { Button, Card, Icon, Statistic } from 'semantic-ui-react'; import './Timer.css'; const Timer: VFC<{ limit: number }> = ({ limit }) => { const [timeLeft, setTimeLeft] = useState(limit); const reset = (): void => setTimeLeft(limit); const tick = (): void => setTimeLeft((t) => t - 1); useEffect(() => { const timerId = setInterval(tick, 1000); return () => clearInterval(timerId); }, []); // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (timeLeft === 0) setTimeLeft(limit); }); return ( <Card> <Statistic className="number-board"> <Statistic.Label>time</Statistic.Label> <Statistic.Value>{timeLeft}</Statistic.Value> </Statistic> <Card.Content> <Button color="red" fluid onClick={reset}> <Icon name="redo" /> Reset </Button> </Card.Content> </Card> ); }; export default Timer; Effect Hook とライフサイクルメソッドの相違点 実行されるタイミング props と state の値の即時性 凝集の単位 1. 実行されるタイミング useEffect が初回実行されるのは、最初のレンダリングが行われてその内容がブラウザ に反映された直後。コンポーネントはまず初期値でレンダリングされた後、あらためて副作用が反 映された内容で再レンダリングされる 2. props と state の値の即時性 クラスコンポーネントのメンバーメソッドで参照される props や state は常に最新の値だけど、そ のため負荷のかかる処理を伴う UI ではタイムラグを考慮する必要がある。処理に時間がかかって レンダリングが追いつかない状態で UI を操作をすると、新しすぎる props や state の値が想定外の 挙動を起こすことがあるの。 いっぽう関数コンポーネント内部におけるそれらはレンダリングのタイミングで固定されている ため、同様の問題は起きない。 ブログ記事『関数コンポーネントはクラスとどうちがうのか?』 3. 凝集の単位 Effect Hook はライフサイクルメソッドに比べて機能的凝集度が高い 機能的凝集度が高いということは、同じ機能が分散して記述されないためコードの可読性が高い のはもちろん、機能によってまとまったロジックをコンポーネントから切り離して再利用しやすい ということでもある Hooks におけるメモ化を理解する メモ化とはプログラム高速化の手法のこと import { VFC, useEffect, useMemo, useState } from 'react'; import { Button, Card, Icon, Statistic } from 'semantic-ui-react'; import { getPrimes } from 'utils/math-tool'; import './Timer.css'; type TimerProps = { limit: number; }; const Timer: VFC<TimerProps> = ({ limit }) => { const [timeLeft, setTimeLeft] = useState(limit); const primes = useMemo(() => getPrimes(limit), [limit]); const reset = () => setTimeLeft(limit); const tick = () => setTimeLeft((t) => t - 1); useEffect(() => { const timerId = setInterval(tick, 1000); return () => clearInterval(timerId); }, []); useEffect(() => { if (timeLeft === 0) setTimeLeft(limit); }, [timeLeft, limit]); return ( <Card> <Statistic className="number-board"> <Statistic.Label>time</Statistic.Label> <Statistic.Value className={primes.includes(timeLeft) ? 'prime-number' : undefined} > {timeLeft} </Statistic.Value> </Statistic> <Card.Content> <Button color="red" fluid onClick={reset}> <Icon name="redo" /> Reset </Button> </Card.Content> </Card> ); }; export default Timer; useMemo を使って計算結果をコンポーネン トシステムの外に保存しておく useMemo は useEffect と同じインターフェース 第 1 引数に実行したい関 数、第 2 引数にその依存配列を渡してる useMemo が関数の実行結果をメモ化する Hooks API だったのに対して、useCallback は関数定義そ のものをメモ化するためのもの メモ化はパフォーマンスの最適化以外に、依存関係を適切化して不要な再レンダリングを避けるためにも用いられることがある useRef は useState とちがって値の変更がコンポーネントの再レンダリングを発生させない Custom Hook でロジックを分離・再利用する コンポーネントから Hooks のロジックを切り出したものを『Custom Hook』って呼ぶ Custom Hook 関数の名前の頭に『use』をつける import { VFC } from 'react'; import { Button, Card, Icon, Statistic } from 'semantic-ui-react'; import useTimer from 'hooks/use-timer'; import 'components/Timer.css'; const Timer: VFC<{ limit: number }> = ({ limit }) => { const [timeLeft, isPrime, reset] = useTimer(limit); return ( <Card> <Statistic className="number-board"> <Statistic.Label>time</Statistic.Label> <Statistic.Value className={isPrime ? 'prime-number' : undefined}> {timeLeft} </Statistic.Value> </Statistic> <Card.Content> <Button color="red" fluid onClick={reset}> <Icon name="redo" /> Reset </Button> </Card.Content> </Card> ); }; export default Timer; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getPrimes } from 'utils/math-tool'; const useTimer = (limit: number): [number, boolean, () => void] => { const [timeLeft, setTimeLeft] = useState(limit); const primes = useMemo(() => getPrimes(limit), [limit]); const timerId = useRef<NodeJS.Timeout>(); const tick = () => setTimeLeft((t) => t - 1); const clearTimer = () => { if (timerId.current) clearInterval(timerId.current); }; const reset = useCallback(() => { clearTimer(); timerId.current = setInterval(tick, 1000); setTimeLeft(limit); }, [limit]); useEffect(() => { reset(); return clearTimer; }, [reset]); useEffect(() => { if (timeLeft === 0) reset(); }, [timeLeft, reset]); return [timeLeft, primes.includes(timeLeft), reset]; }; export default useTimer;
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

大学生がReact.jsとChakra-UIを使ってPortfolioサイトを作ってみた話

portfolioサイトを作りました! この記事を見てくれた皆様ありがとうございます。 文系大学生の4年生絶賛就活中のものです! 今まで自分で1から考えて作ったものがなかったので最近学んだReact.jsを使ってPortfolioサイトを作ろうと考えました。 初学者なのでまだまだ理解できていないところもあるとは思いますが、自分自身のアウトプットのためにこの記事を書いているので、至らない点などあればご指摘ください! はじめに 作ったものをスクショしたものがこちらになります! herokuにアップしているのですがどうもものすごく重い。。。 一様linkも貼っておきます。お時間ある方だけのぞいてくれると喜びます!! heroku↓ https://secret-river-81904.herokuapp.com/ 少しでも気になるという方がいましたらGitHubアカウントを覗いてもらえると泣いて喜びます。 https://github.com/soraoka0418/Portfolio デザインについて 恥ずかしながらデザインの知識が全くありませんでした。 作ってみるかーー!でいきなりコードを書き始めてしまい後から後悔することになりました。。。 もし私と同じようにPortfolioサイト作ってみよう!と思った方がいればはじめにデザインの大まかなものを作ってから始めることを強くお勧めします (当たり前なのかもしれない。。。) ディレクトリ構造について アトミックデザインなるものを採用しました。 (できているのかはわからないが。) 結果的なディレクトリ構造はこちらのようになりました components ├ atoms ───── img │ ├── HeaderLink.js │ └── icon.js │ ├ organisms──── layout ──── Header.js │ │ └── SelfDrawer.js │ ├── Card.js │ ├── HeaderNav.js │ ├── HomeButton.js │ ├── HomeMessage.js │ ├── Selfintroduction.js │ └── SelfLink.js │ └ pages ─── Contact.js ├── Home.js └── Skill.js これについてもあらかじめ決めていたわけではなく、コードを書いていくうちにコードが長くなってしまった為、component化していたらこうなってしまいました。 (計画性ゼロ) 使用した技術について タイトルにも書いている通り、React.jsを使いまいした。今までhtmlやCSS、jQueryでLP制作などをしていたため、React.jsで何かを作るのは初めてでした。 CSSについてはChakra-UIを使用してみました! 個人的にとても便利だと感じた点は、どこにどのスタイルが当たっているか分かりやすい点やもともと用意されているものが豊富で、それをimportするだけで使えるのはとてもありがたかったです!! <Heading as="h3" pb="6" borderBottom="1px" borderColor="teal.500"> {props.title} </Heading> これは見出しの部分のコードですがこんな風にして簡単にスタイルをあてることができます!!詳しくはドキュメントを見てください。。。 →https://chakra-ui.com/ ルーティングについて export const Router = memo(() => { return ( <Switch> <Route exact path="/"> <Home /> </Route> <Route path="/Contact"> <Contact /> </Route> <Route path="/Skill"> <Skill /> </Route> </Switch> ); }); こんな感じでsrcフォルダ配下にrouterフォルダを作り記述しました。 ルーティング周りはまだ苦手意識があるのでこれから克服していきたいと考えています。 最後に もしここまで見てくださった方がいたならとてもうれしい気持ちでいっぱいです。 記事を書いたりするのも初めてのことでいろいろ戸惑いながら書いています 絶賛就活中でエンジニアを志望しているのですがなかなかうまくいかないです 。。。 この記事のご意見ご感想をもらえるととてもモチベーションになりますのでどうか初学者のためだと思って一言よろしくお願いいたします。 (図々しい。。。) それではここまで読んでくださり本当にありがとうございました。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails API + React でマッチングアプリを作ってみた

概要 タイトル通り。バックエンドにRails(API)、フロントエンドにReactを採用したマッチングアプリ的なものを作ってみたのでアウトプットしておきます。 完成イメージ 割とそれっぽい雰囲気になってます。 使用技術 バックエンド Ruby3 Rails6(APIモード) MySQL8 Docker フロントエンド React TypeScript Material-UI 今回はバックエンドのみDockerで環境構築していきます。 実装の流れ だいぶ長旅になるので、これからどういった流れで作業を進めていくのかザックリ整理しておきます。 環境構築 Rails($ rails new) React($ create react-app) 認証機能を作成 gem「devise_token_auth」などを使用 マッチング機能を作成 中間テーブルなどを活用 バックエンドとフロントエンドを分離しているため、あっちこっち手を動かす事になりますが、あらかじめご了承ください。 環境構築 何はともあれ、環境構築からスタートです。 Rails まずはRailsから。 作業ディレクトリ&各種ファイルを作成 $ mkdir rails-react-matching-app && cd rails-react-matching-app $ mkdir backend && cd backend $ touch Dockerfile $ touch docker-compose.yml $ touch entrypoint.sh $ touch Gemfile $ touch Gemfile.lock ./Dockerfile FROM ruby:3.0 RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs ENV APP_PATH /myapp RUN mkdir $APP_PATH WORKDIR $APP_PATH COPY Gemfile $APP_PATH/Gemfile COPY Gemfile.lock $APP_PATH/Gemfile.lock RUN bundle install COPY . $APP_PATH COPY entrypoint.sh /usr/bin/ RUN chmod +x /usr/bin/entrypoint.sh ENTRYPOINT ["entrypoint.sh"] EXPOSE 3000 CMD ["rails", "server", "-b", "0.0.0.0"] ./docker-compose.yml version: "3" services: db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: password command: --default-authentication-plugin=mysql_native_password volumes: - mysql-data:/var/lib/mysql - /tmp/dockerdir:/etc/mysql/conf.d/ ports: - 3306:3306 api: build: context: . dockerfile: Dockerfile command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'" volumes: - .:/myapp - ./vendor/bundle:/myapp/vendor/bundle environment: TZ: Asia/Tokyo RAILS_ENV: development ports: - "3001:3000" depends_on: - db volumes: mysql-data: ./entrypoint.sh #!/bin/bash set -e # Remove a potentially pre-existing server.pid for Rails. rm -f /myapp/tmp/pids/server.pid # Then exec the container's main process (what's set as CMD in the Dockerfile). exec "$@" ./Gemfile # frozen_string_literal: true source "https://rubygems.org" git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } gem "rails", "~> 6" /Gemfile.lock # 空欄でOK 最終的に次のような構成になっていればOK。 rails-react-matching-app └── backend ├── docker-compose.yml ├── Dockerfile ├── entrypoint.sh ├── Gemfile └── Gemfile.lock rails new いつものコマンドでプロジェクトを作成します。 $ docker-compose run api rails new . --force --no-deps -d mysql --api Gemfileが更新されたので再ビルド。 $ docker-compose build database.ymlを編集 ./config/database.yml default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> username: root password: password # デフォルトだと空欄になっているはずなので変更 host: db # デフォルトだとlocalhostになっているはずなので変更 development: <<: *default database: myapp_development test: <<: *default database: myapp_test production: <<: *default database: <%= ENV["DATABASE_NAME"] %> username: <%= ENV["DATABASE_USERNAME"] %> password: <%= ENV["DATABASE_PASSWORD"] %> host: <%= ENV["DATABASE_HOST"] %> データベースを作成 $ docker-compose run api rails db:create localhost:3001にアクセス $ docker-compose up -d localhost:3001 にアクセスしていつもの画面が表示されればOK。 テストAPIを作成 動作確認用のテストAPiを作成します。 $ docker-compose run api rails g controller api/v1/test ./app/controllers/api/v1/test_controller.rb class Api::V1::TestController < ApplicationController def index render json: { status: 200, message: "Hello World!"} end end ./config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :test, only: %i[index] end end end curlコマンドで呼び出してみましょう。 $ curl http://localhost:3001/api/v1/test {"status":200,"message":"Hello World!"} 正常にJSONが返ってくればOK。 CORSを設定 今のままの状態でReact(クライアント)から直接呼び出そうとするとCORSエラーで弾かれてしまうため、その辺の設定を行います。 参照記事: CORSとは? Railsの場合、CORS設定を簡単に行えるgemが存在するのでそちらを使いましょう。 rb./Gemfile gem 'rack-cors' 今回はAPIモードで作成しているため、すでにGemfile内に記載されているはず。(26行目くらい) そちらのコメントアウトを外すだけでOKです。 $ docker-compose build Gemfileを更新したので再度ビルド。 ./config/initializers/cors.rb Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins "localhost:3000" # React側はポート番号3000で作るので「localhost:3000」を指定 resource "*", headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] end end あとは「./config/initializers/cors.rb」をいじくって外部ドメインからアクセス可能なようにしておきます。 React 次にReact側です。 create-react-app おなじみの「create-react-app」でアプリの雛形を作ります。 # ルートディレクトリに移動した後 $ mkdir frontend && cd frontend $ yarn create react-app . --template typescript tsconfig.jsonを修正 ./tsconfig.json { "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, ...省略... "baseUrl": "src" ← 追記 }, "include": [ "src" ] } 「./tsconfig.json」内に「"baseUrl": "src"」という1行を追記してください。 これにより、インポート先を「./tsconfig.json」が配置されているディレクトリから相対パスで指定できるようになるので非常に楽です。 baseUrlを指定しない場合 import Hoge from "../../components/Hoge" // 呼び出すファイルからの相対パス baseUrlを指定した場合 import Hoge from "components/Hoge" // baseUrlからの相対パス 「../../」みたいな記述をしなくて済みます。 不要なファイルを整理 この先使う事の無いファイルは邪魔なので今のうちに消しておきましょう。 $ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts 「./src/index.tsx」と「./src/App.tsx」を次のように変更します。 ./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 localhost:3000 にアクセスして「Hello World!」と返ってくればOK。 APIクライアントを作成 Rails側で作成したAPIを呼び出すための準備を行います。 $ mkdir src/lib $ mkdir src/lib/api $ touch src/lib/api/client.ts $ touch src/lib/api/test.ts $ yarn add axios axios-case-converter $ yarn add -D @types/axios axios HTTPクライアント用のライブラリ @types/axios 型定義用のライブラリ axios-case-converter axiosで受け取ったレスポンスの値をスネークケース→キャメルケースに変換、または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ ./src/lib/api/client.ts import applyCaseMiddleware from "axios-case-converter" import axios from "axios" // applyCaseMiddleware: // axiosで受け取ったレスポンスの値をスネークケース→キャメルケースに変換 // または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ // ヘッダーに関してはケバブケースのままで良いので適用を無視するオプションを追加 const options = { ignoreHeaders: true } const client = applyCaseMiddleware(axios.create({ baseURL: "http://localhost:3001/api/v1" }), options) export default client 慣習的にRubyなどの言語がスネークケースが基本であるのに対し、JavaScriptはキャメルケースが基本となるため、足並みを揃える(スネークケース→キャメルケースへの変換もしくはその逆)ために「applyCaseMiddleware」というライブラリを使わせてもらっています。 ./src/lib/api/test.ts import client from "lib/api/client" // 動作確認用 export const execTest = () => { return client.get("/test") } ./src/App.tsx import React, { useEffect, useState } from "react" import { execTest } from "lib/api/test" const App: React.FC = () => { const [message, setMessage] = useState<string>("") const handleExecTest = async () => { const res = await execTest() if (res.status === 200) { setMessage(res.data.message) } } useEffect(() => { handleExecTest() }, []) return ( <h1>{message}</h1> ) } export default App 再び localhost:3000 にアクセスして「Hello World!」と返ってくればRaiks側との通信に成功です。 もしダメな場合、大抵はDockerのコンテナを再起動していないなどが原因(config/~をいじくったため、反映させるためには再起動する必要がある)なので、 $ docker-compose down $ docker-compose up -d などで再起動をかけてください。 認証機能を作成 環境構築が済んだので、認証機能を作成していきます。 Rails 今回、認証機能は「dvise」および「devise_token_auth」というgemを使って実装します。 deviseをインストール ./Gemfile gem 'devise' gem 'devise_token_auth' Gemfileを更新したので再度ビルド。 $ docker-compose build devise本体とdevise_token_authをインストールし、Userモデルを作成します。 $ docker-compose run api rails g devise:install $ docker-compose run api rails g devise_token_auth:install User auth $ docker-compose run api rails db:migrate 「./app/config/initializers/devise_token_auth.rb」という設定ファイルが自動生成されているはずなので次のように変更してください。 ./app/config/initializers/devise_token_auth.rb # frozen_string_literal: true DeviseTokenAuth.setup do |config| config.change_headers_on_each_request = false config.token_lifespan = 2.weeks config.token_cost = Rails.env.test? ? 4 : 10 config.headers_names = {:'access-token' => 'access-token', :'client' => 'client', :'expiry' => 'expiry', :'uid' => 'uid', :'token-type' => 'token-type' } end また、ヘッダー情報を外部に公開するため、「./config/initializers/cors.rb」を次のように修正します。 ./config/initializers/cors.rb Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins "localhost:3000" # React側はポート番号3000で作るので「localhost:3000」を指定 resource "*", headers: :any, expose: ["access-token", "expiry", "token-type", "uid", "client"], # 追記 methods: [:get, :post, :put, :patch, :delete, :options, :head] end end メール認証設定 今回はサンプルという事もあり、簡略化のためアカウント作成時のメール認証はスキップする方向で進めますが、後ほど実運用を想定した場合は必ず必要になると思うので一応やっておきます。 ./config/environments/development.rb config.action_mailer.default_url_options = { host: 'localhost', port: 3001 } なお、開発環境でメール確認を行う場合は letter_opener_web などが便利だと思います。 各種コントローラーを作成&修正 各種コントローラーの作成および修正を行います。 $ docker-compose run api rails g controller api/v1/auth/registrations $ docker-compose run api rails g controller api/v1/auth/sessions ./app/controllers/api/v1/auth/registrations_controller.rb # アカウント作成用コントローラー class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController private def sign_up_params params.permit(:email, :password, :password_confirmation, :name) end end ./app/controllers/api/v1/auth/sessions_controller.rb # 認証確認用コントローラー class Api::V1::Auth::SessionsController < ApplicationController def index if current_api_v1_user render json: { status: 200, current_user: current_api_v1_user } else render json: { status: 500, message: "ユーザーが存在しません" } end end end ./app/controllers/application_controller.rb class ApplicationController < ActionController::Base include DeviseTokenAuth::Concerns::SetUserByToken skip_before_action :verify_authenticity_token helper_method :current_user, :user_signed_in? end deviseにおいて「current_user」というヘルパーメソッドは定番ですが、今回はルーティングの部分で「api/v1/」というnamespaceを切る(後述)ので「current_api_v1_user」としなければならない点に注意です。 ルーティングを設定 ルーティングの設定もお忘れなく。 ./app/config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :test, only: %i[index] mount_devise_token_auth_for 'User', at: 'auth', controllers: { registrations: 'api/v1/auth/registrations' } namespace :auth do resources :sessions, only: %i[index] end end end end 動作確認 早速ですが、curlコマンドでアカウント作成およびサインインができるか試しみてましょう。 アカウント作成 $ curl -X POST http://localhost:3001/api/v1/auth -d "[name]=test&[email]=test@example.com&[password]=password&[password_confirmation]=password" { "status": "success", "data": { "email": "test@example.com", "uid": "test@example.com", "id": 1, "provider": "email", "allow_password_change": false, "name": "test", "nickname": null, "image": null, "created_at": "2021-06-08T20:27:40.489Z", "updated_at": "2021-06-08T20:27:40.608Z" } } サインイン $ curl -X POST -v http://localhost:3001/api/v1/auth/sign_in -d "[email]=test@example.com&[password]=password" Note: Unnecessary use of -X or --request, POST is already inferred. * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 3001 (#0) > POST /api/v1/auth/sign_in HTTP/1.1 > Host: localhost:3001 > User-Agent: curl/7.64.1 > Accept: */* > Content-Length: 44 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 44 out of 44 bytes < HTTP/1.1 200 OK < X-Frame-Options: SAMEORIGIN < X-XSS-Protection: 1; mode=block < X-Content-Type-Options: nosniff < X-Download-Options: noopen < X-Permitted-Cross-Domain-Policies: none < Referrer-Policy: strict-origin-when-cross-origin < Content-Type: application/json; charset=utf-8 < Vary: Accept, Origin < access-token: xg-4vaua1T2KTUZUFfdYDg < token-type: Bearer < client: 2Rdmffk44hfoFCdmNafMSw < expiry: 1624393713 < uid: test@example.com < ETag: W/"fc564a9145d11564e827b204d5a4ce36" < Cache-Control: max-age=0, private, must-revalidate < X-Request-Id: 9a749e3d-0ba6-4e53-bbfb-1baff500e2e0 < X-Runtime: 0.363334 < Transfer-Encoding: chunked < * Connection #0 to host localhost left intact {"data":{"email":"test@example.com","uid":"test@example.com","id":1,"provider":"email","allow_password_change":false,"name":"test","nickname":null,"image":null}}* Closing connection 0 それぞれこんな感じで返ってくれば無事成功です。 なお、サインイン時に返ってくる access-token client uid この3つは後ほどReact側で認証を行う際に必要となる値なので、重要なものだと頭に入れておきましょう。 React 例の如く次はReact側の実装です。 各種ディレクトリ・ファイルを準備 $ mkdir components $ mkdir components/layouts $ mkdir components/pages $ mkdir components/utils $ mkdir interfaces $ touch components/layouts/CommonLayout.tsx $ touch components/layouts/Header.tsx $ touch components/pages/Home.tsx $ touch components/pages/SignIn.tsx $ touch components/pages/SignUp.tsx $ touch components/utils/AlertMessage.tsx $ touch interfaces/index.ts $ mv components interfaces src $ touch src/lib/api/auth.ts 最終的に次のような構成になっていればOK。 rails-react-auth ├── backend └── frontend ├── node_modules ├── public │   ├── favicon.ico │   ├── index.html │   ├── logo192.png │   ├── logo512.png │   ├── manifest.json │   └── robots.txt ├── src │   ├── components │   │   ├── layouts │   │   │   ├── CommonLayout.tsx │   │   │   └── Header.tsx │   │   ├── pages │   │   │   ├── Home.tsx │   │   │   ├── SignIn.tsx │   │   │   └── SignUp.tsx │   │   └── utils │   │   └── AlertMessage.tsx │   ├── interfaces │   │   └── index.ts │   ├── lib │   │   └── api │   │   ├── auth.ts │   │   ├── client.ts │   │   └── test.ts │   ├── App.tsx │   ├── index.css │   ├── index.tsx │   └── react-app-env.d.ts ├── .gitignore ├── package.json ├── README.md ├── tsconfig.json └── yarn.lock 各種ライブラリをインストール $ yarn add @material-ui/core @material-ui/icons @material-ui/lab @material-ui/pickers @date-io/date-fns@1.3.13 date-fns js-cookie react-router-dom $ yarn add -D @types/js-cookie @types/react-router-dom material-ui UIを整える用のライブラリ date-fns 日付関連を操作するためのライブラリ(v1.3.13じゃないとエラーが発生するので注意) js-cookie Cookieを操作するためのライブラリ react-router-dom ルーティング設定用のライブラリ @types/◯○ 型定義用のライブラリ 型定義 ./src/interfaces/index.ts // サインアップ export interface SignUpData { name: string email: string password: string passwordConfirmation: string } // サインイン export interface SignInData { email: string password: string } // ユーザー export interface User { id: number uid: string provider: string email: string name: string nickname?: string image?: string allowPasswordChange: boolean } 認証API用の関数を作成 ./src/lib/api/auth.ts import client from "lib/api/client" import Cookies from "js-cookie" import { SignUpData, SignInData } from "interfaces/index" // サインアップ(新規アカウント作成) export const signUp = (data: SignUpData) => { return client.post("auth", data) } // サインイン(ログイン) export const signIn = (data: SignInData) => { return client.post("auth/sign_in", data) } // サインアウト(ログアウト) export const signOut = () => { return client.delete("auth/sign_out", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // 認証済みのユーザーを取得 export const getCurrentUser = () => { if (!Cookies.get("_access_token") || !Cookies.get("_client") || !Cookies.get("_uid")) return return client.get("/auth/sessions", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } サインイン中かどうかを判別するための各値(access-token、client、uid)をどこで保持するかについて、色々と議論の余地はあるようですが、今回はCookie内に含める事とします。 参照: React(SPA)での認証についてまとめ 各種ビューを作成 各種ビューの部分を作成します。 ./src/App.tsx import React, { useState, useEffect, createContext } from "react" import { BrowserRouter as Router, Switch, Route, Redirect } from "react-router-dom" import CommonLayout from "components/layouts/CommonLayout" import Home from "components/pages/Home" import SignUp from "components/pages/SignUp" import SignIn from "components/pages/SignIn" import { getCurrentUser } from "lib/api/auth" import { User } from "interfaces/index" // グローバルで扱う変数・関数 export const AuthContext = createContext({} as { loading: boolean setLoading: React.Dispatch<React.SetStateAction<boolean>> isSignedIn: boolean setIsSignedIn: React.Dispatch<React.SetStateAction<boolean>> currentUser: User | undefined setCurrentUser: React.Dispatch<React.SetStateAction<User | undefined>> }) const App: React.FC = () => { const [loading, setLoading] = useState<boolean>(true) const [isSignedIn, setIsSignedIn] = useState<boolean>(false) const [currentUser, setCurrentUser] = useState<User | undefined>() // 認証済みのユーザーがいるかどうかチェック // 確認できた場合はそのユーザーの情報を取得 const handleGetCurrentUser = async () => { try { const res = await getCurrentUser() console.log(res) if (res?.status === 200) { setIsSignedIn(true) setCurrentUser(res?.data.currentUser) } else { console.log("No current user") } } catch (err) { console.log(err) } setLoading(false) } useEffect(() => { handleGetCurrentUser() }, [setCurrentUser]) // ユーザーが認証済みかどうかでルーティングを決定 // 未認証だった場合は「/signin」ページに促す const Private = ({ children }: { children: React.ReactElement }) => { if (!loading) { if (isSignedIn) { return children } else { return <Redirect to="/signin" /> } } else { return <></> } } return ( <Router> <AuthContext.Provider value={{ loading, setLoading, isSignedIn, setIsSignedIn, currentUser, setCurrentUser}}> <CommonLayout> <Switch> <Route exact path="/signup" component={SignUp} /> <Route exact path="/signin" component={SignIn} /> <Private> <Switch> <Route exact path="/" component={Home} /> </Switch> </Private> </Switch> </CommonLayout> </AuthContext.Provider> </Router> ) } export default App ./src/components/layouts/Header.tsx import React, { useContext } from "react" import { useHistory, Link } from "react-router-dom" import Cookies from "js-cookie" 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 { signOut } from "lib/api/auth" import { AuthContext } from "App" const useStyles = makeStyles((theme: Theme) => ({ iconButton: { marginRight: theme.spacing(2), }, title: { flexGrow: 1, textDecoration: "none", color: "inherit" }, linkBtn: { textTransform: "none" } })) const Header: React.FC = () => { const { loading, isSignedIn, setIsSignedIn } = useContext(AuthContext) const classes = useStyles() const histroy = useHistory() const handleSignOut = async (e: React.MouseEvent<HTMLButtonElement>) => { try { const res = await signOut() if (res.data.success === true) { // サインアウト時には各Cookieを削除 Cookies.remove("_access_token") Cookies.remove("_client") Cookies.remove("_uid") setIsSignedIn(false) histroy.push("/signin") console.log("Succeeded in sign out") } else { console.log("Failed in sign out") } } catch (err) { console.log(err) } } const AuthButtons = () => { // 認証完了後はサインアウト用のボタンを表示 // 未認証時は認証用のボタンを表示 if (!loading) { if (isSignedIn) { return ( <Button color="inherit" className={classes.linkBtn} onClick={handleSignOut} > サインアウト </Button> ) } else { return ( <Button component={Link} to="/signin" color="inherit" className={classes.linkBtn} > サインイン </Button> ) } } else { return <></> } } 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> <AuthButtons /> </Toolbar> </AppBar> </> ) } export default Header ./src/components/layouts/CommonLayout.tsx import React from "react" import { Container, Grid } from "@material-ui/core" import { makeStyles } from "@material-ui/core/styles" import Header from "components/layouts/Header" const useStyles = makeStyles(() => ({ container: { marginTop: "3rem" } })) interface CommonLayoutProps { children: React.ReactElement } // 全てのページで共通となるレイアウト const CommonLayout = ({ children }: CommonLayoutProps) => { const classes = useStyles() return ( <> <header> <Header /> </header> <main> <Container maxWidth="lg" className={classes.container}> <Grid container justify="center"> <Grid item> {children} </Grid> </Grid> </Container> </main> </> ) } export default CommonLayout ./src/components/pages/Home.tsx import React, { useContext } from "react" import { AuthContext } from "App" // とりあえず認証済みユーザーの名前やメールアドレスを表示 const Home: React.FC = () => { const { isSignedIn, currentUser } = useContext(AuthContext) return ( <> { isSignedIn && currentUser ? ( <> <h2>メールアドレス: {currentUser?.email}</h2> <h2>名前: {currentUser?.name}</h2> </> ) : ( <></> ) } </> ) } export default Home ./src/components/pages/SignIn.tsx import React, { useState, useContext } from "react" import { useHistory, Link } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import { Typography } from "@material-ui/core" import TextField from "@material-ui/core/TextField" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import CardHeader from "@material-ui/core/CardHeader" import Button from "@material-ui/core/Button" import Box from "@material-ui/core/Box" import { AuthContext } from "App" import AlertMessage from "components/utils/AlertMessage" import { signIn } from "lib/api/auth" import { SignInData } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ container: { marginTop: theme.spacing(6) }, submitBtn: { marginTop: theme.spacing(2), flexGrow: 1, textTransform: "none" }, header: { textAlign: "center" }, card: { padding: theme.spacing(2), maxWidth: 400 }, box: { marginTop: "2rem" }, link: { textDecoration: "none" } })) // サインイン用ページ const SignIn: React.FC = () => { const classes = useStyles() const history = useHistory() const { setIsSignedIn, setCurrentUser } = useContext(AuthContext) const [email, setEmail] = useState<string>("") const [password, setPassword] = useState<string>("") const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data: SignInData = { email: email, password: password } try { const res = await signIn(data) console.log(res) if (res.status === 200) { // 成功した場合はCookieに各値を格納 Cookies.set("_access_token", res.headers["access-token"]) Cookies.set("_client", res.headers["client"]) Cookies.set("_uid", res.headers["uid"]) setIsSignedIn(true) setCurrentUser(res.data.data) history.push("/") console.log("Signed in successfully!") } else { setAlertMessageOpen(true) } } catch (err) { console.log(err) setAlertMessageOpen(true) } } return ( <> <form noValidate autoComplete="off"> <Card className={classes.card}> <CardHeader className={classes.header} title="サインイン" /> <CardContent> <TextField variant="outlined" required fullWidth label="メールアドレス" value={email} margin="dense" onChange={event => setEmail(event.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード" type="password" placeholder="6文字以上" value={password} margin="dense" autoComplete="current-password" onChange={event => setPassword(event.target.value)} /> <div style={{ textAlign: "right"}} > <Button type="submit" variant="outlined" color="primary" disabled={!email || !password ? true : false} className={classes.submitBtn} onClick={handleSubmit} > 送信 </Button> </div> <Box textAlign="center" className={classes.box}> <Typography variant="body2"> まだアカウントをお持ちでない方は <Link to="/signup" className={classes.link}> こちら </Link> から作成してください。 </Typography> </Box> </CardContent> </Card> </form> <AlertMessage // エラーが発生した場合はアラートを表示 open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="error" message="メールアドレスかパスワードが間違っています" /> </> ) } export default SignIn ./src/components/pages/SignUp.tsx import React, { useState, useContext } from "react" import { useHistory } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import TextField from "@material-ui/core/TextField" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import CardHeader from "@material-ui/core/CardHeader" import Button from "@material-ui/core/Button" import { AuthContext } from "App" import AlertMessage from "components/utils/AlertMessage" import { signUp } from "lib/api/auth" import { SignUpData } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ container: { marginTop: theme.spacing(6) }, submitBtn: { marginTop: theme.spacing(2), flexGrow: 1, textTransform: "none" }, header: { textAlign: "center" }, card: { padding: theme.spacing(2), maxWidth: 400 } })) // サインアップ用ページ const SignUp: React.FC = () => { const classes = useStyles() const histroy = useHistory() const { setIsSignedIn, setCurrentUser } = useContext(AuthContext) const [name, setName] = useState<string>("") const [email, setEmail] = useState<string>("") const [password, setPassword] = useState<string>("") const [passwordConfirmation, setPasswordConfirmation] = useState<string>("") const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data: SignUpData = { name: name, email: email, password: password, passwordConfirmation: passwordConfirmation } try { const res = await signUp(data) console.log(res) if (res.status === 200) { // アカウント作成と同時にサインインさせてしまう // 本来であればメール確認などを挟むべきだが、今回はサンプルなので Cookies.set("_access_token", res.headers["access-token"]) Cookies.set("_client", res.headers["client"]) Cookies.set("_uid", res.headers["uid"]) setIsSignedIn(true) setCurrentUser(res.data.data) histroy.push("/") console.log("Signed in successfully!") } else { setAlertMessageOpen(true) } } catch (err) { console.log(err) setAlertMessageOpen(true) } } return ( <> <form noValidate autoComplete="off"> <Card className={classes.card}> <CardHeader className={classes.header} title="サインアップ" /> <CardContent> <TextField variant="outlined" required fullWidth label="名前" value={name} margin="dense" onChange={event => setName(event.target.value)} /> <TextField variant="outlined" required fullWidth label="メールアドレス" value={email} margin="dense" onChange={event => setEmail(event.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード" type="password" value={password} margin="dense" autoComplete="current-password" onChange={event => setPassword(event.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード(確認用)" type="password" value={passwordConfirmation} margin="dense" autoComplete="current-password" onChange={event => setPasswordConfirmation(event.target.value)} /> <div style={{ textAlign: "right"}} > <Button type="submit" variant="outlined" color="primary" disabled={!name || !email || !password || !passwordConfirmation ? true : false} className={classes.submitBtn} onClick={handleSubmit} > 送信 </Button> </div> </CardContent> </Card> </form> <AlertMessage // エラーが発生した場合はアラートを表示 open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="error" message="メールアドレスかパスワードが間違っています" /> </> ) } export default SignUp ./src/components/utils/AlertMessage.tsx import React from "react" import Snackbar from "@material-ui/core/Snackbar" import MuiAlert, { AlertProps } from "@material-ui/lab/Alert" const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert( props, ref, ) { return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} /> }) interface AlertMessageProps { open: boolean setOpen: Function severity: "error" | "success" | "info" | "warning" message: string } // アラートメッセージ(何かアクションを行なった際の案内用に使い回す) const AlertMessage = ({ open, setOpen, severity, message}: AlertMessageProps) => { const handleCloseAlertMessage = (e?: React.SyntheticEvent, reason?: string) => { if (reason === "clickaway") return setOpen(false) } return ( <> <Snackbar open={open} autoHideDuration={6000} anchorOrigin={{ vertical: "top", horizontal: "center" }} onClose={handleCloseAlertMessage} > <Alert onClose={handleCloseAlertMessage} severity={severity}> {message} </Alert> </Snackbar> </> ) } export default AlertMessage 動作確認 サインアップ サインイン トップページ アラートメッセージ 特に問題が無さそうであれば認証機能は完成です。 マッチング機能を作成 だいぶそれっぽい雰囲気になってきたので、最後にマッチング機能を作成していきます。 Rails マッチング機能に関しては中間テーブルなどを活用する事で実現していきます。 テーブル設計 全体的なテーブルはこんな感じです。 ※雑ですみません...。 Userモデルにカラムを追加 deviseで作成したUserモデルに email name image といったカラムがデフォルトで入っていますが、マッチングアプリという観点からするとこれだけの情報ではやや物足りないため、 gender(性別) birthday(誕生日) prefecture(都道府県) profile(自己紹介) といったカラムを別途追加していきたいと思います。 $ docker-compose run api rails g migration AddColumnsToUsers ./db/migrate/20210609035043_add_columns_to_users.rb class AddColumnsToUsers < ActiveRecord::Migration[6.1] def change add_column :users, :gender, :integer, null: false, default: 0, after: :nickname add_column :users, :birthday, :date, after: :email add_column :users, :profile, :string, limit: 1000, after: :birthday add_column :users, :prefecture, :integer, null: false, default: 1, after: :profile remove_column :users, :nickname, :string # 特に使う予定の無いカラムなので削除 end end マイグレーションファイルを上記のように変更後、 $ docker-compose run api rails db:migrate を実行してデータベースに反映させてください。 画像アップロード機能を作成 そういえば、まだ画像アップロード機能が無いので実装していきましょう。今回は定番のgem「carrierwave」を使用します。 ./Gemfile gem 'carrierwave' Gemfileを更新したので再度ビルド。 $ docker-compose build アップローダーを作成。 $ docker-compose run api rails g uploader Image すると「./app/uploaders/image_uploader.rb」が自動生成されるので次のように変更します。 .app/uploaders/image_uploader.rb class ImageUploader < CarrierWave::Uploader::Base storage :file def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end # 受け付け可能なファイルの拡張子を指定 def extension_allowlist %w(jpg jpeg png) end end また、「./config/initializers/」配下にcarrierwave設定用のファイルを作成。 $ touch config/initializers/carrierwave.rb ./config/initializers/carrierwave.rb CarrierWave.configure do |config| config.asset_host = "http://localhost:3001" config.storage = :file config.cache_storage = :file end Userモデルにアップローダーをマウントします。 ./app/models/user.rb # frozen_string_literal: true class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable include DeviseTokenAuth::Concerns::User mount_uploader :image, ImageUploader # 追記 end あとは「./app/controllers/api/v1/auth/registrations_controller.rb」内のストロングパラメータに先ほど追加したカラムを記述しておきます。 ./app/controllers/api/v1/auth/registrations_controller.rb class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController private def sign_up_params params.permit(:email, :password, :password_confirmation, :name, :image, :gender, :prefecture, :birthday) end end これで準備は完了です。 あとは動作確認のためルートディレクトリに適当な画像を「sample.jpg」という名前配置し、次のcurlコマンドを実行しましょう。 $ curl -F "email=imagetest@example.com" -F "password=password" -F "password=password_confirmation" -F "name=imagetest" -F "gender=0" -F "birthday=2000-01-01" -F "prefecture=13" -F "profile=画像テストです" -F "image=@sample.jpg" http://localhost:3001/api/v1/auth { "status": "success", "data": { "email": "imagetest@example.com", "uid": "imagetest@example.com", "image": { "url": "http://localhost:3001/uploads/user/image/3/sample.jpg" }, "id": 3, "provider": "email", "allow_password_change": false, "name": "imagetest", "gender": 0, "birthday": "2000-01-01", "profile": null, "prefecture": 13, "created_at": "2021-06-09T08:53:03.944Z", "updated_at": "2021-06-09T08:53:04.116Z" } } こんな感じで画像のパスが保存されていればOKです。 Likeモデルを作成 今回、マッチングが成立するための条件を 双方のユーザーが相手に対して「いいね」を押す事 とするため、誰が誰に対して押したのかという情報を記録するためのLikeモデルを作成します。 $ docker-compose run api rails g model Like マイグレーションファイルを次のように変更。 ./db/migrate/20210609090711_create_likes.rb class CreateLikes < ActiveRecord::Migration[6.1] def change create_table :likes do |t| t.integer :from_user_id, null: false # 誰が t.integer :to_user_id, null: false # 誰に対して t.timestamps end end end データベースに反映します。 $ docker-compose run api rails db:migrate Userモデルとのリレーションを作成しましょう。 ./app/models/user.rb # frozen_string_literal: true class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable include DeviseTokenAuth::Concerns::User mount_uploader :image, ImageUploader # 以下を追記 has_many :likes_from, class_name: "Like", foreign_key: :from_user_id, dependent: :destroy has_many :likes_to, class_name: "Like", foreign_key: :to_user_id, dependent: :destroy has_many :active_likes, through: :likes_from, source: :to_user # 自分からのいいね has_many :passive_likes, through: :likes_to, source: :from_user # 相手からのいいね end ./app/models/like.rb class Like < ApplicationRecord belongs_to :to_user, class_name: "User", foreign_key: :to_user_id belongs_to :from_user, class_name: "User", foreign_key: :from_user_id end ChatRoomモデル・ChatRoomUserモデルを作成 マッチングが成立した際はメッセージのやりとりを行うための部屋が必要になるため、ChatRoomモデルおよびChatRoomUserモデルを作成します。 ChatRoom メッセージのやりとりを行う部屋 ChatRoomUser どの部屋にどのユーザーがいるのかという情報を記録 $ docker-compose run api rails g model ChatRoom $ docker-compose run api rails g model ChatRoomUser マイグレーションファイルをそれぞれ次のように変更。 ./db/migrate/20210609092254_create_chat_rooms.rb class CreateChatRooms < ActiveRecord::Migration[6.1] def change create_table :chat_rooms do |t| t.timestamps end end end ./db/migrate/20210609092312_create_chat_room_users.rb class CreateChatRoomUsers < ActiveRecord::Migration[6.1] def change create_table :chat_room_users do |t| t.integer :chat_room_id, null: false t.integer :user_id, null: false t.timestamps end end end データベースに反映します。 $ docker-compose run api rails db:migrate User、ChatRoom、ChatRoomUser、それぞれのリレーションを作成しましょう。 ./app/models/user.eb # frozen_string_literal: true class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable include DeviseTokenAuth::Concerns::User mount_uploader :image, ImageUploader has_many :likes_from, class_name: "Like", foreign_key: :from_user_id, dependent: :destroy has_many :likes_to, class_name: "Like", foreign_key: :to_user_id, dependent: :destroy has_many :active_likes, through: :likes_from, source: :to_user # 自分からのいいね has_many :passive_likes, through: :likes_to, source: :from_user # 相手からのいいね # 以下を追記 has_many :chat_room_users has_many :chat_rooms, through: :chat_room_users end ./app/models/chat_room.rb class ChatRoom < ApplicationRecord has_many :chat_room_users has_many :users, through: :chat_room_users # 中間テーブルのchat_room_usersを介してusersを取得 end ./app/models/chat_room_user.rb class ChatRoomUser < ApplicationRecord belongs_to :chat_room belongs_to :user end Messageモデルを作成 あとはメッセージそのものとなるMessageモデルを作成します。なお、メッセージにはどの部屋のものなのか、誰が送信したものなのかといった情報も一緒に記録しておきたいところです。 $ docker-compose run api rails g model Message マイグレーションファイルを次のように変更。 ./db/migrate/20210609093540_create_messages.rb class CreateMessages < ActiveRecord::Migration[6.1] def change create_table :messages do |t| t.integer :chat_room_id, null: false t.integer :user_id, null: false t.string :content, null: false t.timestamps end end end データベースに反映します。 $ docker-compose run api rails db:migrate User、ChatRoomとそれぞれリレーションを作成しましょう。 ./app/models/user.rb # frozen_string_literal: true class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable include DeviseTokenAuth::Concerns::User mount_uploader :image, ImageUploader has_many :likes_from, class_name: "Like", foreign_key: :from_user_id, dependent: :destroy has_many :likes_to, class_name: "Like", foreign_key: :to_user_id, dependent: :destroy has_many :active_likes, through: :likes_from, source: :to_user # 自分からのいいね has_many :passive_likes, through: :likes_to, source: :from_user # 相手からのいいね has_many :chat_room_users has_many :chat_rooms, through: :chat_room_users # 以下を追記 has_many :messages end ./app/models/chat_room.rb class ChatRoom < ApplicationRecord has_many :chat_room_users has_many :users, through: :chat_room_users # 以下を追記 has_many :messages end ./app/models/message.rb class Message < ApplicationRecord belongs_to :chat_room belongs_to :user end 各種コントローラーを作成 上記のモデルたちを操作するためのコントローラーを作成していきます。 $ docker-compose run api rails g controller api/v1/likes $ docker-compose run api rails g controller api/v1/chat_rooms $ docker-compose run api rails g controller api/v1/messages $ docker-compose run api rails g controller api/v1/users ./app/controllers/api/v1/likes_controller.rb class Api::V1::LikesController < ApplicationController def index render json: { status: 200, active_likes: current_api_v1_user.active_likes, # 自分からのいいね passive_likes: current_api_v1_user.passive_likes # 相手からのいいね } end def create is_matched = false # マッチングが成立したかどうかのフラグ active_like = Like.find_or_initialize_by(like_params) passsive_like = Like.find_by( from_user_id: active_like.to_user_id, to_user_id: active_like.from_user_id ) if passsive_like # いいねを押した際、相手からのいいねがすでに存在する場合はマッチング成立 chat_room = ChatRoom.create # メッセージ交換用の部屋を作成 # 自分 ChatRoomUser.find_or_create_by( chat_room_id: chat_room.id, user_id: active_like.from_user_id ) # 相手 ChatRoomUser.find_or_create_by( chat_room_id: chat_room.id, user_id: passsive_like.from_user_id ) is_matched = true end if active_like.save render json: { status: 200, like: active_like, is_matched: is_matched } else render json: { status: 500, message: "作成に失敗しました" } end end private def like_params params.permit(:from_user_id, :to_user_id) end end ./app/controllers/api/v1/chat_rooms_controller.rb class Api::V1::ChatRoomsController < ApplicationController before_action :set_chat_room, only: %i[show] def index chat_rooms = [] current_api_v1_user.chat_rooms.order("created_at DESC").each do |chat_room| # 部屋の情報(相手のユーザーは誰か、最後に送信されたメッセージはどれか)をJSON形式で作成 chat_rooms << { chat_room: chat_room, other_user: chat_room.users.where.not(id: current_api_v1_user.id)[0], last_message: chat_room.messages[-1] } end render json: { status: 200, chat_rooms: chat_rooms } end def show other_user = @chat_room.users.where.not(id: current_api_v1_user.id)[0] messages = @chat_room.messages.order("created_at ASC") render json: { status: 200, other_user: other_user, messages: messages } end private def set_chat_room @chat_room = ChatRoom.find(params[:id]) end end ./app/controllers/api/v1/messages_controller.rb class Api::V1::MessagesController < ApplicationController def create message = Message.new(message_params) if message.save render json: { status: 200, message: message } else render json: { status: 500, message: "作成に失敗しました" } end end private def message_params params.permit(:chat_room_id, :user_id, :content) end end ./app/controllers/api/v1/users_controller.rb class Api::V1::UsersController < ApplicationController before_action :set_user, only: %i[show update] def index # 都道府県が同じで性別の異なるユーザーを取得(自分以外) users = User.where(prefecture: current_api_v1_user.prefecture).where.not(id: current_api_v1_user.id, gender: current_api_v1_user.gender).order("created_at DESC") render json: { status: 200, users: users } end def show render json: { status: 200, user: @user } end def update @user.name = user_params[:name] @user.prefecture = user_params[:prefecture] @user.profile = user_params[:profile] @user.image = user_params[:image] if user_params[:image] != "" if @user.save render json: { status: 200, user: @user } else render json: { status: 500, message: "更新に失敗しました" } end end private def set_user @user = User.find(params[:id]) end def user_params params.permit(:name, :prefecture, :profile, :image) end end 各種ルーティングを設定 最後にルーティングの設定を行えばRails側の準備は全て完了です。 ./config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :test, only: %i[index] resources :likes, only: %i[index create] resources :chat_rooms, only: %i[index show] resources :messages, only: %i[create] resources :users, only: %i[index show update] mount_devise_token_auth_for 'User', at: 'auth', controllers: { registrations: 'api/v1/auth/registrations' } namespace :auth do resources :sessions, only: %i[index] end end end end React いよいよ仕上げです。Railsで準備したAPIを呼び出しつつ、ビューの部分を作り込んでいきます。 各種ディレクトリ・ファイルを準備 $ touch src/components/pages/Users.tsx $ touch src/components/pages/ChatRooms.tsx $ touch src/components/pages/ChatRoom.tsx $ touch src/components/pages/NotFound.tsx $ mkdir src/data $ touch src/data/genders.ts $ touch src/data/prefectures.ts $ touch src/lib/api/users.ts $ touch src/lib/api/likes.ts $ touch src/lib/api/chat_rooms.ts $ touch src/lib/api/messages.ts 最終的に次のような構成になっていればOK。 rails-react-matching-app ├── backend └── frontend ├── node_modules ├── public │   ├── favicon.ico │   ├── index.html │   ├── logo192.png │   ├── logo512.png │   ├── manifest.json │   └── robots.txt ├── src │   ├── components │   │   ├── layouts │   │   │   ├── CommonLayout.tsx │   │   │   └── Header.tsx │   │   ├── pages │   │   │   ├── ChatRoom.tsx │   │   │   ├── ChatRooms.tsx │   │   │   ├── Home.tsx │   │   │   ├── NotFound.tsx │   │   │   ├── SignIn.tsx │   │   │   ├── SignUp.tsx │   │   │   └── Users.tsx │   │   └── utils │   │   └── AlertMessage.tsx │   ├── data │   │   ├── genders.ts │   │   └── prefectures.ts │   ├── interfaces │   │   └── index.ts │   ├── lib │   │   └── api │   │   ├── auth.ts │   │   ├── chat_rooms.ts │   │   ├── client.ts │   │   ├── likes.ts │   │   ├── messages.ts │   │   ├── test.ts │   │   └── users.ts │   ├── App.tsx │   ├── index.css │   ├── index.tsx │   └── react-app-env.d.ts ├── tsconfig.json └── yarn.lock 型定義 ./src/interfaces/index.ts // サインアップ export interface SignUpData { name: string email: string password: string passwordConfirmation: string gender: number prefecture: number birthday: Date image: string } export interface SignUpFormData extends FormData { append(name: keyof SignUpData, value: String | Blob, fileName?: string): any } // サインイン export interface SignInData { email: string password: string } // ユーザー export interface User { id: number uid: string provider: string email: string name: string image: { url: string } gender: number birthday: String | number | Date profile: string prefecture: number allowPasswordChange: boolean createdAt?: Date updatedAt?: Date } export interface UpdateUserData { id: number | undefined | null name?: string prefecture?: number profile?: string image?: string } export interface UpdateUserFormData extends FormData { append(name: keyof UpdateUserData, value: String | Blob, fileName?: string): any } // いいね export interface Like { id?: number fromUserId: number | undefined | null toUserId: number | undefined | null } // チャットルーム export interface ChatRoom { chatRoom: { id: number } otherUser: User, lastMessage: Message } // メッセージ export interface Message { chatRoomId: number userId: number | undefined content: string createdAt?: Date } マスターデータを作成 性別や都道府県といった不変的な情報はマスターデータとして保持しておきます。 ./src/data/genders.ts // 性別のマスターデータ export const genders: string[] = [ "男性", "女性", "その他" ] ./src/data/prefectures.ts // 都道府県のマスターデータ export const prefectures: string[] = [ "北海道", "青森県", "岩手県", "宮城県", "秋田県", "山形県", "福島県", "茨城県", "栃木県", "群馬県", "埼玉県", "千葉県", "東京都", "神奈川県", "新潟県", "富山県", "石川県", "福井県", "山梨県", "長野県", "岐阜県", "静岡県", "愛知県", "三重県", "滋賀県", "京都府", "大阪府", "兵庫県", "奈良県", "和歌山県", "鳥取県", "島根県", "岡山県", "広島県", "山口県", "徳島県", "香川県", "愛媛県", "高知県", "福岡県", "佐賀県", "長崎県", "熊本県", "大分県", "宮崎県", "鹿児島県", "沖縄県" ] API用の関数を作成 ./src/lib/api/auth.ts import client from "lib/api/client" import Cookies from "js-cookie" import { SignUpFormData, SignInData } from "interfaces/index" // サインアップ export const signUp = (data: SignUpFormData) => { return client.post("auth", data) } // サインイン export const signIn = (data: SignInData) => { return client.post("auth/sign_in", data) } // サインアウト export const signOut = () => { return client.delete("auth/sign_out", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // 認証中ユーザーの情報を取得 export const getCurrentUser = () => { if (!Cookies.get("_access_token") || !Cookies.get("_client") || !Cookies.get("_uid")) return return client.get("auth/sessions", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } ./src/lib/api/users.ts import client from "lib/api/client" import { UpdateUserFormData} from "interfaces/index" import Cookies from "js-cookie" // 都道府県が同じで性別の異なるユーザー情報一覧を取得(自分以外) export const getUsers = () => { return client.get("users", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // id指定でユーザー情報を個別に取得 export const getUser = (id: number | undefined) => { return client.get(`users/${id}`) } // ユーザー情報を更新 export const updateUser = (id: number | undefined | null, data: UpdateUserFormData) => { return client.put(`users/${id}`, data) } ./src/lib/api/likes.ts import client from "lib/api/client" import { Like } from "interfaces/index" import Cookies from "js-cookie" // 全てのいいね情報(自分から、相手から両方)を取得 export const getLikes = () => { return client.get("likes", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // いいねを作成 export const createLike= (data: Like) => { return client.post("likes", data) } ./src/lib/api/chat_rooms.ts import client from "lib/api/client" import Cookies from "js-cookie" // マッチングしたユーザーとの全てのチャットルーム情報を取得 export const getChatRooms = () => { return client.get("chat_rooms", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // id指定でチャットルーム情報を個別に取得 export const getChatRoom = (id: number) => { return client.get(`chat_rooms/${id}`, { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } ./src/lib/api/messages.ts import client from "lib/api/client" import { Message } from "interfaces/index" // メッセージを作成 export const createMessage = (data: Message) => { return client.post("messages", data) } 各種ビューを作成 各種ビューの部分を作成します。 ./src/components/layouts/CommonLayout.tsx import React from "react" import { Container, Grid } from "@material-ui/core" import { makeStyles } from "@material-ui/core/styles" import Header from "components/layouts/Header" const useStyles = makeStyles(() => ({ container: { marginTop: "3rem" } })) interface CommonLayoutProps { children: React.ReactElement } // 全てのページで共通となるレイアウト const CommonLayout = ({ children }: CommonLayoutProps) => { const classes = useStyles() return ( <> <header> <Header /> </header> <main> <Container maxWidth="lg" className={classes.container}> <Grid container justify="center"> {children} </Grid> </Container> </main> </> ) } export default CommonLayout ./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 IconButton from "@material-ui/core/IconButton" import ExitToAppIcon from "@material-ui/icons/ExitToApp" import PersonIcon from "@material-ui/icons/Person" import SearchIcon from "@material-ui/icons/Search" import ChatBubbleIcon from "@material-ui/icons/ChatBubble" import { AuthContext } from "App" const useStyles = makeStyles((theme: Theme) => ({ title: { flexGrow: 1, textDecoration: "none", color: "inherit" }, linkBtn: { textTransform: "none", marginLeft: theme.spacing(1) } })) const Header: React.FC = () => { const { loading, isSignedIn } = useContext(AuthContext) const classes = useStyles() // 認証済みかどうかで表示ボタンを変更 const AuthButtons = () => { if (!loading) { if (isSignedIn) { return ( <> <IconButton component={Link} to="/users" edge="start" className={classes.linkBtn} color="inherit" > <SearchIcon /> </IconButton> <IconButton component={Link} to="/chat_rooms" edge="start" className={classes.linkBtn} color="inherit" > <ChatBubbleIcon /> </IconButton> <IconButton component={Link} to="/home" edge="start" className={classes.linkBtn} color="inherit" > <PersonIcon /> </IconButton> </> ) } else { return ( <> <IconButton component={Link} to="/signin" edge="start" className={classes.linkBtn} color="inherit" > <ExitToAppIcon /> </IconButton> </> ) } } else { return <></> } } return ( <> <AppBar position="static"> <Toolbar> <Typography component={Link} to="/users" variant="h6" className={classes.title} > Sample </Typography> <AuthButtons /> </Toolbar> </AppBar> </> ) } export default Header ./src/components/pages/SignIn.tsx import React, { useState, useContext } from "react" import { useHistory, Link } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import { Typography } from "@material-ui/core" import TextField from "@material-ui/core/TextField" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import CardHeader from "@material-ui/core/CardHeader" import Button from "@material-ui/core/Button" import Box from "@material-ui/core/Box" import { AuthContext } from "App" import AlertMessage from "components/utils/AlertMessage" import { signIn } from "lib/api/auth" import { SignInData } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ container: { marginTop: theme.spacing(6) }, submitBtn: { marginTop: theme.spacing(2), flexGrow: 1, textTransform: "none" }, header: { textAlign: "center" }, card: { padding: theme.spacing(2), maxWidth: 340 }, box: { marginTop: "2rem" }, link: { textDecoration: "none" } })) // サインインページ const SignIn: React.FC = () => { const classes = useStyles() const history = useHistory() const { setIsSignedIn, setCurrentUser } = useContext(AuthContext) const [email, setEmail] = useState<string>("") const [password, setPassword] = useState<string>("") const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data: SignInData = { email: email, password: password } try { const res = await signIn(data) console.log(res) if (res.status === 200) { // ログインに成功した場合はCookieに各情報を格納 Cookies.set("_access_token", res.headers["access-token"]) Cookies.set("_client", res.headers["client"]) Cookies.set("_uid", res.headers["uid"]) setIsSignedIn(true) setCurrentUser(res.data.data) history.push("/home") setEmail("") setPassword("") console.log("Signed in successfully!") } else { setAlertMessageOpen(true) } } catch (err) { console.log(err) setAlertMessageOpen(true) } } return ( <> <form noValidate autoComplete="off"> <Card className={classes.card}> <CardHeader className={classes.header} title="サインイン" /> <CardContent> <TextField variant="outlined" required fullWidth label="メールアドレス" value={email} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード" type="password" placeholder="最低6文字以上" value={password} margin="dense" autoComplete="current-password" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)} /> <div style={{ textAlign: "right"}} > <Button type="submit" variant="outlined" color="primary" disabled={!email || !password ? true : false} // 空欄があった場合はボタンを押せないように className={classes.submitBtn} onClick={handleSubmit} > 送信 </Button> </div> <Box textAlign="center" className={classes.box}> <Typography variant="body2"> まだアカウントをお持ちでない方は <Link to="/signup" className={classes.link}> こちら </Link> から作成してください。 </Typography> </Box> </CardContent> </Card> </form> <AlertMessage // エラーが発生した場合はアラートを表示 open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="error" message="メールアドレスかパスワードが間違っています" /> </> ) } export default SignIn ./src/components/pages/SignUp.tsx import React, { useState, useContext, useCallback } from "react" import { useHistory } from "react-router-dom" import Cookies from "js-cookie" import "date-fns" import DateFnsUtils from "@date-io/date-fns" // バージョンに注意(https://stackoverflow.com/questions/59600125/cannot-get-material-ui-datepicker-to-work) import { makeStyles, Theme } from "@material-ui/core/styles" import Grid from "@material-ui/core/Grid" import TextField from "@material-ui/core/TextField" import InputLabel from "@material-ui/core/InputLabel" import MenuItem from "@material-ui/core/MenuItem" import FormControl from "@material-ui/core/FormControl" import Select from "@material-ui/core/Select" import { MuiPickersUtilsProvider, KeyboardDatePicker } from "@material-ui/pickers" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import CardHeader from "@material-ui/core/CardHeader" import Button from "@material-ui/core/Button" import IconButton from "@material-ui/core/IconButton" import PhotoCamera from "@material-ui/icons/PhotoCamera" import Box from "@material-ui/core/Box" import CancelIcon from "@material-ui/icons/Cancel" import { AuthContext } from "App" import AlertMessage from "components/utils/AlertMessage" import { signUp } from "lib/api/auth" import { SignUpFormData } from "interfaces/index" import { prefectures } from "data/prefectures" import { genders } from "data/genders" const useStyles = makeStyles((theme: Theme) => ({ container: { marginTop: theme.spacing(6) }, submitBtn: { marginTop: theme.spacing(1), flexGrow: 1, textTransform: "none" }, header: { textAlign: "center" }, card: { padding: theme.spacing(2), maxWidth: 340 }, inputFileButton: { textTransform: "none", color: theme.palette.primary.main }, imageUploadBtn: { textAlign: "right" }, input: { display: "none" }, box: { marginBottom: "1.5rem" }, preview: { width: "100%" } })) // サインアップページ const SignUp: React.FC = () => { const classes = useStyles() const histroy = useHistory() const { setIsSignedIn, setCurrentUser } = useContext(AuthContext) const [name, setName] = useState<string>("") const [email, setEmail] = useState<string>("") const [password, setPassword] = useState<string>("") const [passwordConfirmation, setPasswordConfirmation] = useState<string>("") const [gender, setGender] = useState<number>() const [prefecture, setPrefecture] = useState<number>() const [birthday, setBirthday] = useState<Date | null>( new Date("2000-01-01T00:00:00"), ) const [image, setImage] = useState<string>("") const [preview, setPreview] = useState<string>("") const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) // アップロードした画像のデータを取得 const uploadImage = useCallback((e) => { const file = e.target.files[0] setImage(file) }, []) // 画像プレビューを表示 const previewImage = useCallback((e) => { const file = e.target.files[0] setPreview(window.URL.createObjectURL(file)) }, []) // フォームデータを作成 const createFormData = (): SignUpFormData => { const formData = new FormData() formData.append("name", name) formData.append("email", email) formData.append("password", password) formData.append("passwordConfirmation", passwordConfirmation) formData.append("gender", String(gender)) formData.append("prefecture", String(prefecture)) formData.append("birthday", String(birthday)) formData.append("image", image) return formData } const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data = createFormData() try { const res = await signUp(data) console.log(res) if (res.status === 200) { Cookies.set("_access_token", res.headers["access-token"]) Cookies.set("_client", res.headers["client"]) Cookies.set("_uid", res.headers["uid"]) setIsSignedIn(true) setCurrentUser(res.data.data) histroy.push("/home") setName("") setEmail("") setPassword("") setPasswordConfirmation("") setGender(undefined) setPrefecture(undefined) setBirthday(null) console.log("Signed in successfully!") } else { setAlertMessageOpen(true) } } catch (err) { console.log(err) setAlertMessageOpen(true) } } return ( <> <form noValidate autoComplete="off"> <Card className={classes.card}> <CardHeader className={classes.header} title="サインアップ" /> <CardContent> <TextField variant="outlined" required fullWidth label="名前" value={name} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)} /> <TextField variant="outlined" required fullWidth label="メールアドレス" value={email} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード" type="password" value={password} margin="dense" autoComplete="current-password" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード(確認用)" type="password" value={passwordConfirmation} margin="dense" autoComplete="current-password" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordConfirmation(e.target.value)} /> <FormControl variant="outlined" margin="dense" fullWidth > <InputLabel id="demo-simple-select-outlined-label">性別</InputLabel> <Select labelId="demo-simple-select-outlined-label" id="demo-simple-select-outlined" value={gender} onChange={(e: React.ChangeEvent<{ value: unknown }>) => setGender(e.target.value as number)} label="性別" > { genders.map((gender: string, index: number) => <MenuItem value={index}>{gender}</MenuItem> ) } </Select> </FormControl> <FormControl variant="outlined" margin="dense" fullWidth > <InputLabel id="demo-simple-select-outlined-label">都道府県</InputLabel> <Select labelId="demo-simple-select-outlined-label" id="demo-simple-select-outlined" value={prefecture} onChange={(e: React.ChangeEvent<{ value: unknown }>) => setPrefecture(e.target.value as number)} label="都道府県" > { prefectures.map((prefecture, index) => <MenuItem key={index +1} value={index + 1}>{prefecture}</MenuItem> ) } </Select> </FormControl> <MuiPickersUtilsProvider utils={DateFnsUtils}> <Grid container justify="space-around"> <KeyboardDatePicker fullWidth inputVariant="outlined" margin="dense" id="date-picker-dialog" label="誕生日" format="MM/dd/yyyy" value={birthday} onChange={(date: Date | null) => { setBirthday(date) }} KeyboardButtonProps={{ "aria-label": "change date", }} /> </Grid> </MuiPickersUtilsProvider> <div className={classes.imageUploadBtn}> <input accept="image/*" className={classes.input} id="icon-button-file" type="file" onChange={(e: React.ChangeEvent<HTMLInputElement>) => { uploadImage(e) previewImage(e) }} /> <label htmlFor="icon-button-file"> <IconButton color="primary" aria-label="upload picture" component="span" > <PhotoCamera /> </IconButton> </label> </div> { preview ? ( <Box className={classes.box} > <IconButton color="inherit" onClick={() => setPreview("")} > <CancelIcon /> </IconButton> <img src={preview} alt="preview img" className={classes.preview} /> </Box> ) : null } <div style={{ textAlign: "right"}} > <Button type="submit" variant="outlined" color="primary" disabled={!name || !email || !password || !passwordConfirmation ? true : false} // 空欄があった場合はボタンを押せないように className={classes.submitBtn} onClick={handleSubmit} > 送信 </Button> </div> </CardContent> </Card> </form> <AlertMessage // エラーが発生した場合はアラートを表示 open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="error" message="メールアドレスかパスワードが間違っています" /> </> ) } export default SignUp ./src/components/pages/Users.tsx import React, { useState, useEffect, useContext } from "react" import { makeStyles, Theme } from "@material-ui/core/styles" import { Grid, Typography } from "@material-ui/core" import Dialog from "@material-ui/core/Dialog" import DialogContent from "@material-ui/core/DialogContent" import Avatar from "@material-ui/core/Avatar" import Button from "@material-ui/core/Button" import Divider from "@material-ui/core/Divider" import FavoriteIcon from "@material-ui/icons/Favorite" import FavoriteBorderIcon from "@material-ui/icons/FavoriteBorder" import AlertMessage from "components/utils/AlertMessage" import { prefectures } from "data/prefectures" import { getUsers } from "lib/api/users" import { getLikes, createLike } from "lib/api/likes" import { User, Like } from "interfaces/index" import { AuthContext } from "App" const useStyles = makeStyles((theme: Theme) => ({ avatar: { width: theme.spacing(10), height: theme.spacing(10) } })) // ユーザー一覧ページ const Users: React.FC = () => { const { currentUser } = useContext(AuthContext) const classes = useStyles() const initialUserState: User = { id: 0, uid: "", provider: "", email: "", name: "", image: { url: "" }, gender: 0, birthday: "", profile: "", prefecture: 13, allowPasswordChange: true } const [loading, setLoading] = useState<boolean>(true) const [users, setUsers] = useState<User[]>([]) const [user, setUser] = useState<User>(initialUserState) const [userDetailOpen, setUserDetailOpen] = useState<boolean>(false) const [likedUsers, setLikedUsers] = useState<User[]>([]) const [likes, setLikes] = useState<Like[]>([]) const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) // 生年月日から年齢を計算する 年齢 = floor((今日 - 誕生日) / 10000) const userAge = (): number | void => { const birthday = user.birthday.toString().replace(/-/g, "") if (birthday.length !== 8) return const date = new Date() const today = date.getFullYear() + ("0" + (date.getMonth() + 1)).slice(-2) + ("0" + date.getDate()).slice(-2) return Math.floor((parseInt(today) - parseInt(birthday)) / 10000) } // 都道府県 const userPrefecture = (): string => { return prefectures[(user.prefecture) - 1] } // いいね作成 const handleCreateLike = async (user: User) => { const data: Like = { fromUserId: currentUser?.id, toUserId: user.id } try { const res = await createLike(data) console.log(res) if (res?.status === 200) { setLikes([res.data.like, ...likes]) setLikedUsers([user, ...likedUsers]) console.log(res?.data.like) } else { console.log("Failed") } if (res?.data.isMatched === true) { setAlertMessageOpen(true) setUserDetailOpen(false) } } catch (err) { console.log(err) } } // ユーザー一覧を取得 const handleGetUsers = async () => { try { const res = await getUsers() console.log(res) if (res?.status === 200) { setUsers(res?.data.users) } else { console.log("No users") } } catch (err) { console.log(err) } setLoading(false) } // いいね一覧を取得 const handleGetLikes = async () => { try { const res = await getLikes() console.log(res) if (res?.status === 200) { setLikedUsers(res?.data.activeLikes) } else { console.log("No likes") } } catch (err) { console.log(err) } } useEffect(() => { handleGetUsers() handleGetLikes() }, []) // すでにいいねを押されているユーザーかどうかの判定 const isLikedUser = (userId: number | undefined): boolean => { return likedUsers?.some((likedUser: User) => likedUser.id === userId) } return ( <> { !loading ? ( users?.length > 0 ? ( <Grid container justify="center"> { users?.map((user: User, index: number) => { return ( <div key={index} onClick={() => { setUser(user) setUserDetailOpen(true) }}> <Grid item style={{ margin: "0.5rem", cursor: "pointer" }}> <Avatar alt="avatar" src={user?.image.url} className={classes.avatar} /> <Typography variant="body2" component="p" gutterBottom style={{ marginTop: "0.5rem", textAlign: "center" }} > {user.name} </Typography> </Grid> </div> ) }) } </Grid> ) : ( <Typography component="p" variant="body2" color="textSecondary" > まだ1人もユーザーがいません。 </Typography> ) ) : ( <></> ) } <Dialog open={userDetailOpen} keepMounted onClose={() => setUserDetailOpen(false)} > <DialogContent> <Grid container justify="center"> <Grid item> <Avatar alt="avatar" src={user?.image.url} className={classes.avatar} /> </Grid> </Grid> <Grid container justify="center"> <Grid item style={{ marginTop: "1rem" }}> <Typography variant="body1" component="p" gutterBottom style={{ textAlign: "center" }}> {user.name} {userAge()}歳 ({userPrefecture()}) </Typography> <Divider /> <Typography variant="body2" component="p" gutterBottom style={{ marginTop: "0.5rem", fontWeight: "bold" }} > 自己紹介 </Typography> <Typography variant="body2" component="p" color="textSecondary" style={{ marginTop: "0.5rem" }}> {user.profile ? user.profile : "よろしくお願いします。" } </Typography> </Grid> </Grid> <Grid container justify="center"> <Button variant="outlined" onClick={() => isLikedUser(user.id) ? void(0) : handleCreateLike(user)} color="secondary" startIcon={isLikedUser(user.id) ? <FavoriteIcon /> : <FavoriteBorderIcon />} disabled={isLikedUser(user.id) ? true : false} style={{ marginTop: "1rem", marginBottom: "1rem" }} > {isLikedUser(user.id) ? "いいね済み" : "いいね"} </Button> </Grid> </DialogContent> </Dialog> <AlertMessage open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="success" message="マッチングが成立しました!" /> </> ) } export default Users ./src/components/pages/ChatRooms.tsx import React, { useEffect, useState } from "react" import { Link } from "react-router-dom" import { makeStyles, Theme } from "@material-ui/core/styles" import { Grid, Typography } from "@material-ui/core" import Avatar from "@material-ui/core/Avatar" import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import Divider from '@material-ui/core/Divider'; import ListItemText from '@material-ui/core/ListItemText'; import ListItemAvatar from '@material-ui/core/ListItemAvatar'; import { getChatRooms } from "lib/api/chat_rooms" import { ChatRoom } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ root: { flexGrow: 1, minWidth: 340, maxWidth: "100%" }, link: { textDecoration: "none", color: "inherit" } })) // チャットルーム一覧ページ const ChatRooms: React.FC = () => { const classes = useStyles() const [loading, setLoading] = useState<boolean>(true) const [chatRooms, setChatRooms] = useState<ChatRoom[]>([]) const handleGetChatRooms = async () => { try { const res = await getChatRooms() if (res.status === 200) { setChatRooms(res.data.chatRooms) } else { console.log("No chat rooms") } } catch (err) { console.log(err) } setLoading(false) } useEffect(() => { handleGetChatRooms() }, []) return ( <> { !loading ? ( chatRooms.length > 0 ? ( chatRooms.map((chatRoom: ChatRoom, index: number) => { return ( <Grid container key={index} justify="center"> <List> {/* 個別のチャットルームへ飛ばす */} <Link to={`/chatroom/${chatRoom.chatRoom.id}`} className={classes.link}> <div className={classes.root}> <ListItem alignItems="flex-start" style={{padding: 0 }}> <ListItemAvatar> <Avatar alt="avatar" src={chatRoom.otherUser.image.url} /> </ListItemAvatar> <ListItemText primary={chatRoom.otherUser.name} secondary={ <div style={{ marginTop: "0.5rem" }}> <Typography component="span" variant="body2" color="textSecondary" > {chatRoom.lastMessage === null ? "まだメッセージはありません。" : chatRoom.lastMessage.content.length > 30 ? chatRoom.lastMessage.content.substr(0, 30) + "..." : chatRoom.lastMessage.content} </Typography> </div> } /> </ListItem> </div> </Link> <Divider component="li" /> </List> </Grid> ) }) ) : ( <Typography component="p" variant="body2" color="textSecondary" > マッチング中の相手はいません。 </Typography> ) ) : ( <></> ) } </> ) } export default ChatRooms ./src/components/pages/ChatRoom.tsx import React, { useEffect, useState, useContext } from "react" import { RouteComponentProps } from "react-router-dom" import { makeStyles, Theme } from "@material-ui/core/styles" import { Grid, Typography } from "@material-ui/core" import Avatar from "@material-ui/core/Avatar" import TextField from "@material-ui/core/TextField" import Box from "@material-ui/core/Box" import Button from "@material-ui/core/Button" import SendIcon from "@material-ui/icons/Send" import { getChatRoom } from "lib/api/chat_rooms" import { createMessage } from "lib/api/messages" import { User, Message } from "interfaces/index" import { AuthContext } from "App" const useStyles = makeStyles((theme: Theme) => ({ avatar: { width: theme.spacing(10), height: theme.spacing(10), margin: "0 auto" }, formWrapper : { padding: "2px 4px", display: "flex", alignItems: "center", width: 340 }, textInputWrapper : { width: "100%" }, button: { marginLeft: theme.spacing(1) } })) type ChatRoomProps = RouteComponentProps<{ id: string }> // 個別のチャットルームページ const ChatRoom: React.FC<ChatRoomProps> = (props) => { const classes = useStyles() const { currentUser } = useContext(AuthContext) const id = parseInt(props.match.params.id) // URLからidを取得 const [loading, setLoading] = useState<boolean>(true) const [otherUser, setOtherUser] = useState<User>() const [messages, setMeesages] = useState<Message[]>([]) const [content, setContent] = useState<string>("") const handleGetChatRoom = async () => { try { const res = await getChatRoom(id) console.log(res) if (res?.status === 200) { setOtherUser(res?.data.otherUser) setMeesages(res?.data.messages) } else { console.log("No other user") } } catch (err) { console.log(err) } setLoading(false) } useEffect(() => { handleGetChatRoom() }, []) const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data: Message = { chatRoomId: id, userId: currentUser?.id, content: content } try { const res = await createMessage(data) console.log(res) if (res.status === 200) { setMeesages([...messages, res.data.message]) setContent("") } } catch (err) { console.log(err) } } // Railsから渡ってくるtimestamp(ISO8601)をdatetimeに変換 const iso8601ToDateTime = (iso8601: string) => { const date = new Date(Date.parse(iso8601)) const year = date.getFullYear() const month = date.getMonth() + 1 const day = date.getDate() const hour = date.getHours() const minute = date.getMinutes() return year + "年" + month + "月" + day + "日" + hour + "時" + minute + "分" } return ( <> { !loading ? ( <div style={{ maxWidth: 360 }}> <Grid container justify="center" style={{ marginBottom: "1rem" }}> <Grid item> <Avatar alt="avatar" src={otherUser?.image.url || ""} className={classes.avatar} /> <Typography variant="body2" component="p" gutterBottom style={{ marginTop: "0.5rem", marginBottom: "1rem", textAlign: "center" }} > {otherUser?.name} </Typography> </Grid> </Grid> { messages.map((message: Message, index: number) => { return ( <Grid key={index} container justify={message.userId === otherUser?.id ? "flex-start" : "flex-end"}> <Grid item> <Box borderRadius={message.userId === otherUser?.id ? "30px 30px 30px 0px" : "30px 30px 0px 30px"} bgcolor={message.userId === otherUser?.id ? "#d3d3d3" : "#ffb6c1"} color={message.userId === otherUser?.id ? "#000000" : "#ffffff"} m={1} border={0} style={{ padding: "1rem" }} > <Typography variant="body1" component="p"> {message.content} </Typography> </Box> <Typography variant="body2" component="p" color="textSecondary" style={{ textAlign: message.userId === otherUser?.id ? "left" : "right" }} > {iso8601ToDateTime(message.createdAt?.toString() || "100000000")} </Typography> </Grid> </Grid> ) }) } <Grid container justify="center" style={{ marginTop: "2rem" }}> <form className={classes.formWrapper} noValidate autoComplete="off"> <TextField required multiline value={content} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setContent(e.target.value)} className={classes.textInputWrapper} /> <Button variant="contained" color="primary" disabled={!content ? true : false} onClick={handleSubmit} className={classes.button} > <SendIcon /> </Button> </form> </Grid> </div> ) : ( <></> ) } </> ) } export default ChatRoom ./src/components/pages/Home.tsx import React, { useContext, useEffect, useState, useCallback } from "react" import { useHistory } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import { Grid, Typography } from "@material-ui/core" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import IconButton from "@material-ui/core/IconButton" import SettingsIcon from "@material-ui/icons/Settings" import Dialog from "@material-ui/core/Dialog" import TextField from "@material-ui/core/TextField" import DialogActions from "@material-ui/core/DialogActions" import DialogContent from "@material-ui/core/DialogContent" import DialogTitle from "@material-ui/core/DialogTitle" import InputLabel from "@material-ui/core/InputLabel" import MenuItem from "@material-ui/core/MenuItem" import FormControl from "@material-ui/core/FormControl" import Select from "@material-ui/core/Select" import PhotoCamera from "@material-ui/icons/PhotoCamera" import Box from "@material-ui/core/Box" import CancelIcon from "@material-ui/icons/Cancel" import ExitToAppIcon from "@material-ui/icons/ExitToApp" import Button from "@material-ui/core/Button" import Avatar from "@material-ui/core/Avatar" import Divider from "@material-ui/core/Divider" import { AuthContext } from "App" import { prefectures } from "data/prefectures" import { signOut } from "lib/api/auth" import { getUser, updateUser } from "lib/api/users" import { UpdateUserFormData } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ avatar: { width: theme.spacing(10), height: theme.spacing(10) }, card: { width: 340 }, imageUploadBtn: { textAlign: "right" }, input: { display: "none" }, box: { marginBottom: "1.5rem" }, preview: { width: "100%" } })) // ホーム(マイページ的な) const Home: React.FC = () => { const { isSignedIn, setIsSignedIn, currentUser, setCurrentUser } = useContext(AuthContext) const classes = useStyles() const histroy = useHistory() const [editFormOpen, setEditFormOpen] = useState<boolean>(false) const [name, setName] = useState<string | undefined>(currentUser?.name) const [prefecture, setPrefecture] = useState<number | undefined>(currentUser?.prefecture || 0) const [profile, setProfile] = useState<string | undefined>(currentUser?.profile) const [image, setImage] = useState<string>("") const [preview, setPreview] = useState<string>("") // アップロードした画像の情報を取得 const uploadImage = useCallback((e) => { const file = e.target.files[0] setImage(file) }, []) // 画像プレビュー const previewImage = useCallback((e) => { const file = e.target.files[0] setPreview(window.URL.createObjectURL(file)) }, []) // 生年月日から年齢を計算する 年齢 = floor((今日 - 誕生日) / 10000) const currentUserAge = (): number | void => { const birthday = currentUser?.birthday.toString().replace(/-/g, "") || "" if (birthday.length !== 8) return const date = new Date() const today = date.getFullYear() + ("0" + (date.getMonth() + 1)).slice(-2) + ("0" + date.getDate()).slice(-2) return Math.floor((parseInt(today) - parseInt(birthday)) / 10000) } // 都道府県 const currentUserPrefecture = (): string => { return prefectures[(currentUser?.prefecture || 0) - 1] } const createFormData = (): UpdateUserFormData => { const formData = new FormData() formData.append("name", name || "") formData.append("prefecture", String(prefecture)) formData.append("profile", profile || "") formData.append("image", image) return formData } const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data = createFormData() try { const res = await updateUser(currentUser?.id, data) console.log(res) if (res.status === 200) { setEditFormOpen(false) setCurrentUser(res.data.user) console.log("Update user successfully!") } else { console.log(res.data.message) } } catch (err) { console.log(err) console.log("Failed in updating user!") } } // サインアウト用の処理 const handleSignOut = async (e: React.MouseEvent<HTMLButtonElement>) => { try { const res = await signOut() if (res.data.success === true) { // Cookieから各情報を削除 Cookies.remove("_access_token") Cookies.remove("_client") Cookies.remove("_uid") setIsSignedIn(false) histroy.push("/signin") console.log("Succeeded in sign out") } else { console.log("Failed in sign out") } } catch (err) { console.log(err) } } return ( <> { isSignedIn && currentUser ? ( <> <Card className={classes.card}> <CardContent> <Grid container justify="flex-end"> <Grid item> <IconButton onClick={() => setEditFormOpen(true)} > <SettingsIcon color="action" fontSize="small" /> </IconButton> </Grid> </Grid> <Grid container justify="center"> <Grid item> <Avatar alt="avatar" src={currentUser?.image.url} className={classes.avatar} /> </Grid> </Grid> <Grid container justify="center"> <Grid item style={{ marginTop: "1.5rem"}}> <Typography variant="body1" component="p" gutterBottom> {currentUser?.name} {currentUserAge()}歳 ({currentUserPrefecture()}) </Typography> <Divider style={{ marginTop: "0.5rem"}}/> <Typography variant="body2" component="p" gutterBottom style={{ marginTop: "0.5rem", fontWeight: "bold" }} > 自己紹介 </Typography> { currentUser.profile ? ( <Typography variant="body2" component="p" color="textSecondary"> {currentUser.profile} </Typography> ): ( <Typography variant="body2" component="p" color="textSecondary"> よろしくお願いいたします。 </Typography> ) } <Button variant="outlined" onClick={handleSignOut} color="primary" fullWidth startIcon={<ExitToAppIcon />} style={{ marginTop: "1rem"}} > サインアウト </Button> </Grid> </Grid> </CardContent> </Card> <form noValidate autoComplete="off"> <Dialog open={editFormOpen} keepMounted onClose={() => setEditFormOpen(false)} > <DialogTitle style={{ textAlign: "center"}}> プロフィールの変更 </DialogTitle> <DialogContent> <TextField variant="outlined" required fullWidth label="名前" value={name} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)} /> <FormControl variant="outlined" margin="dense" fullWidth > <InputLabel id="demo-simple-select-outlined-label">都道府県</InputLabel> <Select labelId="demo-simple-select-outlined-label" id="demo-simple-select-outlined" value={prefecture} onChange={(e: React.ChangeEvent<{ value: unknown }>) => setPrefecture(e.target.value as number)} label="都道府県" > { prefectures.map((prefecture, index) => <MenuItem key={index + 1} value={index + 1}>{prefecture}</MenuItem> ) } </Select> </FormControl> <TextField placeholder="1000文字以内で書いてください。" variant="outlined" multiline fullWidth label="自己紹介" rows="8" value={profile} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => { setProfile(e.target.value) }} /> <div className={classes.imageUploadBtn}> <input accept="image/*" className={classes.input} id="icon-button-file" type="file" onChange={(e: React.ChangeEvent<HTMLInputElement>) => { uploadImage(e) previewImage(e) }} /> <label htmlFor="icon-button-file"> <IconButton color="primary" aria-label="upload picture" component="span" > <PhotoCamera /> </IconButton> </label> </div> { preview ? ( <Box className={classes.box} > <IconButton color="inherit" onClick={() => setPreview("")} > <CancelIcon /> </IconButton> <img src={preview} alt="preview img" className={classes.preview} /> </Box> ) : null } </DialogContent> <DialogActions> <Button onClick={handleSubmit} color="primary" disabled={!name || !profile ? true : false} > 送信 </Button> </DialogActions> </Dialog> </form> </> ) : ( <></> ) } </> ) } export default Home ./src/components/pages/NotFound.tsx import React from "react" // 存在しないページにアクセスされた場合の表示 const NotFound: React.FC = () => { return ( <h2>404 Not Found</h2> ) } export default NotFound ./src/components/App.tsx import React, { useState, useEffect, createContext } from "react" import { BrowserRouter as Router, Switch, Route, Redirect } from "react-router-dom" import CommonLayout from "components/layouts/CommonLayout" import Home from "components/pages/Home" import ChatRooms from "components/pages/ChatRooms" import ChatRoom from "components/pages/ChatRoom" import Users from "components/pages/Users" import SignUp from "components/pages/SignUp" import SignIn from "components/pages/SignIn" import NotFound from "components/pages/NotFound" import { getCurrentUser } from "lib/api/auth" import { User } from "interfaces/index" // グローバルで扱う変数・関数(contextで管理) export const AuthContext = createContext({} as { loading: boolean isSignedIn: boolean setIsSignedIn: React.Dispatch<React.SetStateAction<boolean>> currentUser: User | undefined setCurrentUser: React.Dispatch<React.SetStateAction<User | undefined>> }) const App: React.FC = () => { const [loading, setLoading] = useState<boolean>(true) const [isSignedIn, setIsSignedIn] = useState<boolean>(false) const [currentUser, setCurrentUser] = useState<User | undefined>() const handleGetCurrentUser = async () => { try { const res = await getCurrentUser() console.log(res) if (res?.status === 200) { setIsSignedIn(true) setCurrentUser(res?.data.currentUser) } else { console.log("No current user") } } catch (err) { console.log(err) } setLoading(false) } useEffect(() => { handleGetCurrentUser() }, [setCurrentUser]) // ユーザーが認証済みかどうかでルーティングを決定 // 未認証だった場合は「/signin」ページに促す const Private = ({ children }: { children: React.ReactElement }) => { if (!loading) { if (isSignedIn) { return children } else { return <Redirect to="/signin" /> } } else { return <></> } } return ( <Router> <AuthContext.Provider value={{ loading, isSignedIn, setIsSignedIn, currentUser, setCurrentUser }}> <CommonLayout> <Switch> <Route exact path="/signup" component={SignUp} /> <Route exact path="/signin" component={SignIn} /> <Private> <Switch> <Route exact path="/home" component={Home} /> <Route exact path="/users" component={Users} /> <Route exact path="/chat_rooms" component={ChatRooms} /> <Route path="/chatroom/:id" component={ChatRoom} /> <Route component={NotFound} /> </Switch> </Private> </Switch> </CommonLayout> </AuthContext.Provider> </Router> ) } export default App 動作確認 あとは全体的に問題が無いか動作確認しましょう。 サインアップ サインイン ホーム ユーザー一覧 マッチング一覧 チャットルーム ※ユーザーデータなどは各自適当に入れてください。 あとがき 以上、Rails APIモード + React + Material-UIでマッチングアプリを作ってみました。 だいぶコード量が多いので、所々で雑になってしまっているかもしれません。特にエラーハンドリングの部分とか全然できてないと思います...。あとスタイルの当て方もだいぶ雑な気が(笑) 今回はあくまでサンプルという事で、細かい部分の調整は各自お好みでお願いします。 こんな感じで趣味カードみたいなものを追加するとよりそれっぽくなるかも。これベースに色々工夫してみていただけると幸いです。 一応、GitHubのリンクも載せておくので、もし動かない部分などあったらそちらと照らし合わせて間違ってる部分は無いか確認してください。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

BCD Design の Common を徹底解説

はじめに ドワンゴでニコニコ生放送のWebフロントエンジニアをやっています、 @misuken です。 今回は BCD Design の共通の関心事である Common(Common Domain) に関して徹底解説していきます。 BCD Design とは 2020年3月に発表した BCD Design によるコンポーネントの分類 概念軸による分類 BCD Design はコンポーネント名に使用される単語の意味や性質を相対的に利用することで、 コンポーネントを Base Case Domain の3つの概念へ法則的に配置し、体系的に管理できるようにする分類手法です。 元々は AtomicDesign でモヤモヤする問題を解決するコンポーネント分類手法として考案されたものでしたが、 BCD Design のスキルは色々な分野で幅広く通用することもわかってきたので、Webフロントやコンポーネントに関係のない分野の方も、元記事やこの内容を参考に、今まで抱えていたモヤモヤをスッキリしていただけると幸いです。 Common の解明 以前発表した BCD Design の記事 では、共通の関心事である Common に関してあまり詳しい説明が書いてありませんでした。 あのときはまだ完全な確信に至れていない部分があり、軽く触れるにとどまっていたのですが、あれからもずっと BCD Design の本質と向き合い続けた結果、明確な答えに辿り着いたので発表したいと思います。 Common の役割 関心事である Domain には、そのサービスに強く依存する関心の領域と、大体いつでもどこでも共通で存在する関心の領域(単語自体が持つ性質とも言える)の二つが存在しています。 例えば、 Tag や Category といった関心は、どのサービスでもほとんど差のない共通の関心であり、サービス独自の関心の領域が無いと言えることもある一方、 Member Site User などは、サービス独自の関心の領域もありつつ、どのサービス間でもほぼ共通の関心の領域を持っています。 このような場合は次のような構成で捉えることができます。 ※ 以前 BCD Design を発表したときと少し移動したものもあります Common の部分を一般的な関心の領域、Domain の部分をサービス独自の関心の領域として見たとき、それぞれに Member Site User があるものの、その境界の存在には大きな価値があります。 Common の真髄 例えば、あなたがサービスを作った後、また新しいサービスを作ることを想像してみてください。 前のサービスと差の生じる部分というのはサービス独自の関心領域であるため、 Domain の領域だけ作り変えれば良いということを示しています。 他にも MemberCategory と UserCategory を持つサービスがあった場合、その二つの Category はサービスに強い依存を持つと思われますが、二つの Category に精通する Category としての性質、つまり Category のみの関心の領域は、他のサービスでも十分利用可能な内容になっているはずです。 このように捉えることで、 Domain ではサービス独自の関心が煮詰まった高凝集な領域が手に入るので、大規模なサービスでは特に Common (共通の関心という意味)という分類を用意するメリットが大きくなります。 現実的な話 実際にはそこまでキレイに Common からサービス独自の関心を取り除くことは難しいかもしれません。 しかし、 Domain をそのサービスで特に強い関心のある単語のみの構成にすることには、別の側面からも大きな意味があります。 サービスを開発する際のほとんどは、そのサービスの特に強い関心に対して開発を行っていくことが多いでしょう。 その際、強い関心のあるディレクトリが大量のディレクトリに埋もれていると、探しにくく、ちゃんと管理できているのか不安になったりします。 そこで、サービスの特に強い関心のみを Domain に並べます。 すると、そのサービスが主にどのような関心で構築されているのかが明確になり、そこに重要な関心が凝集されていて辿る順序もキレイになるため、新しいメンバーなどにも優しい環境になります。(把握や学習のコストも低減されます) Common は必須ではない もちろん、必ず Common が必要なわけではありません。 Domain のディレクトリに全部突っ込んでも問題ないという運用もあるでしょう。 関心のサービス依存度の高低や、一般的な関心との境界がある場合、 Category や Error や Tag といった細かい関心と分けたい場合など、そのサービスでこうあってほしい(開発がしやすい)と思う Domain ディレクトリ像を決めて、残りは Common へ移動という形がスムーズでしょう。 依存関係の注意点 Domain と Common の間で一点だけ注意しておく必要があるのは、 Domain から Common を参照することは良いのですが、 Common から Domain を参照することは避ける必要があります。 参照方向が単一になっていないと、循環参照や複雑さの原因になるためです。 もし循環参照になりそうな場合は、必ず Common 側から依存したい Domain の一部(そこは一般的な関心に近いはずです)を Common のほうに分離しましょう。 つまり、 User の例で言うと、 Domain にあった User の中から、 一般的な関心に近い部分を切り出し、 Common 側に書くようにすると、 Domain に残ったサービス依存度の強い User からも、 Common の User からも参照できる位置関係になるので、 Common 内では FollowerUser という表現が可能になります。 このように、 Domain と Common という違う特性を持った関心の領域が二段構えにあることで、どれだけ関心が増えたとしても、常に複雑さを最小にでき、適切に管理可能な構成を手に入れられます。 まとめ 一般的な関心や複数の Domain で共通する関心の領域は Common が向いています Domain 内ではサービス依存の高い関心を中心に構成するとサービス自体を把握しやすくなります 関心の中でもサービス依存度の高い部分と汎用的な部分をしっかりと境界付けられます 通常は BaseXxx のような存在を作りたくなるシーンでも Common が使える可能性が高いです Domain と Common に同じ名前のディレクトリがあっても問題ありません 要は同じ関心の対象であっても汎用的にしたい部分とそうでない部分があるからです 依存関係の循環には気を付けましょう Domain と Common の境界がうまく分けられない場合は、無理せず Domain のみで運用しましょう 確実に移動可能なものは Common に移動するなど少しずつでも問題ありません Common を使い回して Domain に集中できる環境が整うと非常に便利です。 ぜひ、Domain にメインの関心事を集中させて、薄くて高凝集の理想的な形を手に入れてください。 おまけ 最近社内LTにて、「BCD Design 単語表」というものを発表しました。 これはニコニコ生放送におけるコンポーネント名の単語ほぼ全てを分解し、 Base Case Common Domain に分類したもので、非常に興味深いものになっているので、そう遠くないうちに公開できればと思っています。 また、この BCD Design の流れから、関心の対象に様々な形容詞が掛け合わさるなど、複雑な文脈の違いを要求される場面でも、関心が分散せず、理解しやすく、法則的にスケールし続けるディレクトリ構成も確立されたので、そちらも公開予定です。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactの基本的な用語をまとめてみた

Reactアプリを作ったが基礎的な用語でも理解できていないところがあったので、調べてみた。 React関連 コンポーネント UIを構成する部品 最小単位はHTMLタグ一つ分 クラスコンポーネントと関数コンポーネントの2種類がある。 React 16.7以降はhook機能の追加によって、シンプルに記述できる関数コンポーネントで記述するのが一般的 Unityで例えるとGameObjectに近い ※同語のComponentではない state コンポーネントごとに持つ、コンポーネントの状態を管理する仕組み。 静的(static)変数の代わりに使う。※ちなみに他言語と違ってstaticは予約語ではない。 スコープ(アクセス範囲)はプライベートと同様の扱いとなる。 読み込みは変数と同様の扱いできるが、書き込み時はsetState(),useState()等React特有の記述が必要。 stateを更新すると、参照しているコンポーネントは自動で再読み込みされる。 子コンポーネントにpropsで渡すことはできるが、親コンポーネントにstateを渡すことはできない。 状態管理ライブラリ別のstateのアクセス可能範囲(スコープ) Redux 全コンポーネントからアクセス可能で、Store(stateの集まり)はプロジェクトに一つだけしか作成できない。 グローバル変数の様に扱える。 便利だが、記述量が多いので実装が大変。 Recoil 指定したコンポーネントのみアクセス可能で、atom(stateの集まり)は複数個作成できる。 Reduxより実装は楽。 どちらも使用しない場合 自コンポーネント内または、親コンポーネントからprops(引数)としてstateを受け取るバケツリレー方式でコンポーネントを跨いでstateを渡す。 孫コンポーネントとか複数階層が出てくると対応できなくなる(対応すると煩雑なコードになる)場合がある。 小規模なアプリだったらこれでも良いかも。 props 親コンポーネントから子コンポーネントへ値を渡す仕組み 要するにコンポーネント呼び出し時に渡す引数 hook 2020年のReact 16.8から使えるようになった。 関数コンポーネント内でクラスコンポーネントでしか使用できなかった機能を使用できるようにする関数の種類。 上記stateの項目のuseState()もhookの一種 本機能の追加により、これまでクラスコンポーネントでしか扱えなかったライフサイクルの機能が関数コンポーネントでも扱えるようになった。 ライフサイクル コンポーネント毎の各種タイミングによって実行される関数 種類 tick() 毎秒実行 componentDidMount() DOM として描画されるタイミングで実行 componentWillUnmount() DOMが削除されるタイミングで実行 Unityで例えるとstart,update等の"スクリプトライフサイクル"に近い React Router ユーザーからの入力に応じて表示させるページを出し分ける機能 ユーザーがリクエストしたURLとコンポーネントを紐づける(ルーティングする)ことができる。 ページ間の移動時は、不足しているコンポーネントのみ更新する。(SPAの動作になる) ルーティングはサーバー側ではなく、クライアント側で行い、ルート先のコンポーネントをサーバーに要求する動作を行う。   TypeScript関連(基本的に言語固有のもの) any型 動的型付け言語の変数型と同様に扱える型 戻り値等の型が読めない場合に使える。 JavaScriptからの移植が面倒な時に… スプレッド演算子(...) オブジェクトのメンバーを展開して設定することができる。 プロパティ名と値を全部書きたい時にめちゃくちゃ便利 多分ほかの言語にはない アロー関数 他言語のラムダ式、無名関数と同じ interface 多言語同様にインターフェース(実装)に使用する。 拡張する(可能性のあるもの含めて)型定義はTypeではなくInterfaceを使う。 加えてオブジェクトの構造を宣言するための多くの機能がある。 同名のinterfaceを記述すると"Declaration merging"(エラーにならずに統合)が発生するので注意。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【React】useStateを使ったら無限ループしたので、対処した。Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.

症状 useStateを使用したら、無限ループが発生してしまいました。 翻訳すると、「再レンダリングが多すぎます。 React は、無限ループを防ぐためにレンダリングの数を制限します。」です。 error Error: Too many re-renders. React limits the number of renders to prevent an infinite loop. Hoge.jsx import React, {useState} from "react"; export const Hoge= () =>{ const [count,setCount] = useState(0); const handleHoge= () => { setCount(count+1) } return( <div> <div>{count}回押された</div> <button onClick={handleHoge()}>HOGEButton</button> </div> ) } 解決策 Clickイベントをアローにしたら、無限ループ解決しました。 render内をアロー関数で書かないと、render時にuseStateが呼ばれてしまい、再びrenderされ、そこでもuseState→renderm→useState→render...というループに陥るみたいです。 Hoge.jsx import React, {useState} from "react"; export const Hoge= () =>{ const [count,setCount] = useState(0); const handleHoge= () => { setCount(count+1) } return( <div> <div>{count}回押された</div> <button onClick={() => handleHoge()}>HOGEButton</button> </div> ) } 参考
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む