1. 本稿の目的
React を勉強する一環として学習記録アプリを作成してみました。
作る過程で得た知識の定着を目的として、以下に作成方法をまとめていきます。
また、本稿では React や TypeScript の文法については細かく説明しませんので、適宜公式ドキュメントを参照してください。
以下が使用する主な技術のドキュメントです。
2. 環境
ツール | バージョン |
---|---|
macOS Ventura | 13.2.1 |
Node.js | 18.16.0 |
React | 18.2.0 |
TypeScript | 4.9.5 |
Firebase | 9 |
3. 環境構築
3.1 Visual Studio Code
3.1.1 インストール
フロントエンド開発では、Visual Studio Code(VSCode)が一般的に使用されています。
他の IDE(統合開発環境)と比べても非常に軽量です。TypeScript の開発元である Microsoft の製品でもあるため、特別なこだわりがない限り VSCode をお勧めします。
(ちなみに個人的には WebStorm が好きです。)
VSCode は以下の公式サイトからダウンロードできます。
3.1.2 拡張機能の追加
VSCode を起動すると左のサイドバーに四角が積み重なったようなマークがありますのでクリックして開きます。
検索窓にeslint
と入力して以下のプラグインをインストールします。
(私の場合は既にインストールしているので表記が異なっていますが、初めての場合はインストール
というボタンが表示されます。)
同じように以下のプラグインをインストールします。
プラグインの詳細については、インストール画面の説明やプラグインのホームページ、GitHub などを読んでみてください。
また、以下のプラグインは必須ではありませんが、おすすめのプラグインです。
3.2 Node.js
3.2.1 インストール
React プロジェクトを作成するために、Node.js をインストールする必要があります。
Node.js とはサーバサイドで動く JavaScript のことです。
Node.js はプロジェクトごとに使用するバージョンが変化するため、公式の Web サイトから直接インストールするよりもバージョン管理ツールを用いるのが一般的かと思います。
個人的に Volta が使いやすくてお勧めです。
また、バージョン管理ツールはいいからとりあえず動かしたいという方であれば、以下の Progate の解説がわかりやすいので読んでみてください。
以下の記事内では 16 系を使用していますが、本稿で使用する Node のバージョンは 18.16.0 です。
3.2.2 バージョンの確認
Node.js のインストールが完了したらターミナルでバージョンを確認します。
node -v
というコマンドを実行して以下のようにバージョンが表示されたらインストール成功です。
~ % node -v
v18.16.0
3.3 Create React App
3.3.1 プロジェクトの作成
今回利用するのはCreate React App
という React で Single Page Application(SPA)を簡単に作成することができるコマンドです。
お使いのターミナルでプロジェクトを作成したいディレクトリに移動し、以下のコマンドを実行します。
--template typesript
と末尾につけることによって TypeSciprt をインストールした状態の React プロジェクトを作成してくれます。
npx create-react-app just-do-it --template typescript
just-do-it
の部分は任意のプロジェクト名(お好きな名前)でオッケーです。
本稿のデモアプリでは学習を記録するアプリのため、メンタル強めの名前にさせていただきました。
実行後のメッセージの最終行にHappy hacking!
出てきたら成功です。
3.3.2 起動確認
現在いるディレクトリの直下に 3.3.1 のコマンドで React プロジェクトが作成されました 🎉
まずは起動確認するために、プロジェクトディレクトリに移動します。
cd just-do-it
続けて、起動コマンドを叩きます。
npm start
自動でブラウザを立ち上げてhttp://localhost:3000
にアクセスしてくれるはずです。
自動でブラウザが立ち上がらなかった方は、手動でアクセスしてみてください。
終了させたい時はctrl
+ C
で終了できます。
3.3.3 不要なファイルの削除
VSCode で作成したプロジェクトを開きます。
src 直下の以下のファイルは今回使用しないため、削除しておきます。
3.3.4 不要な記述の削除
- body {
- margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
- sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- }
-
- code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
- }
import React from 'react';
- import logo from './logo.svg';
- import './App.css';
function App() {
return (
- <div className="App">
- <header className="App-header">
- <img src={logo} className="App-logo" alt="logo" />
- <p>
- Edit <code>src/App.tsx</code> and save to reload.
- </p>
- <a
- className="App-link"
- href="https://reactjs.org"
- target="_blank"
- rel="noopener noreferrer"
- >
- Learn React
- </a>
- </header>
- </div>
+ <div>Hello, world!</div>
);
}
export default App;
※後の動作確認用に 1 行だけ追加しています。
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
- import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
- // If you want to start measuring performance in your app, pass a function
- // to log results (for example: reportWebVitals(console.log))
- // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
- reportWebVitals();
3.3.5 動作確認
npm start
以下のようにHello, world!
と表示されていれば成功です。
画面に表示されている文字列は、先ほど App.tsx に記述した<div>Hello, world!</div>
の部分です。
index.tsx が読み込まれ、return 文の中で App コンポーネント(App.tsx から export されているもの)が呼び出されています。
さらに呼び出された App コンポーネントから div 要素を返却することで画面へ描画しています。
3.4 settings.json
プロジェクトの作成が完了したところで、一旦 VSCode の設定にもどりたいと思います。
設定はそれぞれ好みがあるかと思いますので、本項ではプロジェクトごとに設定する方法を案内します。
-
出てきた設定画面のタブをワークスペースに切り替えます。
ユーザータブはグローバルな設定ができます。つまり VSCode 全体に設定するのでどのプロジェクトを開いても適用されます。
一方、ワークスペース
はプロジェクトごとに設定が作成されます。
それぞれお好みに合わせて使ってみてください。 -
VSCode の右上に下記のようなアイコンがありますので、クリックして`settings.jsonファイルを開いてください。
ワークスペースタブからsettings.jsonを開くと、プロジェクトに.vscode/settings.json
が自動生成されます。
Git にあげればチームで共有することも可能です。
{
"editor.bracketPairColorization.enabled": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.minimap.enabled": false,
"editor.renderWhitespace": "all",
"editor.tabSize": 2,
"explorer.sortOrder": "type",
"files.autoSave": "onFocusChange",
"typescript.referencesCodeLens.enabled": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
それぞれの設定の内容は以下の通りです。
設定 | 内容 |
---|---|
"editor.bracketPairColorization.enabled": true, | 括弧の対応を示すために色付けを有効にします。 |
"editor.defaultFormatter": "esbenp.prettier-vscode", | コードを自動フォーマットするフォーマッターに Prettier を指定 |
"editor.formatOnSave": true, | ファイル保存時にコードフォーマットを実施 |
"editor.minimap.enabled": false, | ミニマップ(右側に出てくるエディタファイルの全体像)を無効化 |
"editor.renderWhitespace": "all", | 空白文字を表示 |
"editor.tabSize": 2, | タブサイズをスペース 2 個分に設定 |
"explorer.sortOrder": "type", | エクスプローラーの並べ替えを拡張子ごとに分ける |
"files.autoSave": "onFocusChange", | エディタがフォーカスを失うと自動保存 |
"typescript.referencesCodeLens.enabled": true, | 関数や変数の被 import 数を表示できる |
"editor.codeActionsOnSave": { "source.fixAll.eslint": true } | 保存時に ESLint をもとに自動修正 |
3.5 ESLint ・ Prettier
3.5.1 ignore ファイルの作成
ESLint と Prettier を入れる前に先に ignore ファイルを作ってしまいます。
後でプラグインを入れた時に予期しないコードの整形とかが走るとバグの原因になるので...
プロジェクトの直下に以下のファイルを新規作成します。
ESLint が解析をしないファイルおよびディレクトリを指定
**/node_modules/*
**/build/*
/.eslintrc.js
/tailwind.config.js
続いて Prettier がチェックしないファイルおよびディレクトリを指定
node_modules
build
package-lock.json
public
3.5.2 インストール
ESLint とは JavaScript のコード解析ツールです。
https://eslint.org/docs/latest/use/getting-started
バグの原因の早期発見やコードの可動性を上げることができます。
次のコマンドを使用して、ESLint をインストールおよび構成していきます。
npm init @eslint/config
実行すると以下のように質問がされるので答えていきます。
各質問はお好みに合わせて設定を進めてください。
複数選択肢が場合はお使いのキーボードの矢印
キーでカーソルを移動できます。
Enter
キーで決定です。
本稿で選択した設定はコードブロック左上のラベルに記載しておきます。
また質問で聞かれている内容をざっくりとコードブロックの上に書いておきます。
@eslint/create-config
というパッケージをインストールする必要があるが進めても良いか?
Need to install the following packages:
@eslint/create-config@0.4.3
Ok to proceed? (y)
ESLint をどのように使いたいか
? How would you like to use ESLint? …
❯ To check syntax only
To check syntax and find problems
To check syntax, find problems, and enforce code style
プロジェクトで使用したいモジュールシステムの種類
? What type of modules does your project use? …
❯ JavaScript modules (import/export)
CommonJS (require/exports)
None of these
どのフレームワークで使用するか
? Which framework does your project use? …
❯ React
Vue.js
None of these
プロジェクトで TypeScript を使うか
? Does your project use TypeScript? › No / Yes
どこでコードを動かすか
? Where does your code run? … (Press <space> to select, <a> to toggle all, <i> to invert selection)
✔ Browser
✔ Node
構成ファイルはどの形式が良いか
? What format do you want your config file to be in? …
❯ JavaScript
YAML
JSON
次のパッケージをインストールする必要があるのでインストールしてもいいか
Local ESLint installation not found.
The config that you've selected requires the following dependencies:
eslint-plugin-react@latest @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest eslint@latest
? Would you like to install them now? › No / Yes
パッケージマネージャーはどれを使用するか
? Which package manager do you want to use? …
❯ npm
yarn
pnpm
全ての質問に答えて実行したあとに
Successfully created .eslintrc.js file in /[プロジェクトを保存しているディレクトリ]/just-do-it
というメッセージが表示されたら成功です。
3.5.3 ESLint のプラグインと Prettier をインストール
下記のコマンドで、今回 ESLint に設定するプラグインをインストールします。
npm install --save-dev eslint-config-prettier eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-simple-import-sort eslint-plugin-sort-keys-custom-order eslint-plugin-unused-imports npm-run-all prettier
上記でインストールしているものの概要はこちらです。
パッケージ名 | 概要 |
---|---|
eslint-config-prettier | 不要な、または Prettier と競合する可能性のあるすべてのルールをオフにする。 |
eslint-plugin-import | ES6 モジュールを使用する際に、モジュールの読み込みに関する問題を検出するための ESLint プラグイン。 |
eslint-plugin-jsx-a11y | React アプリケーションで、アクセシビリティに関する問題を検出するための ESLint プラグイン。 |
eslint-plugin-react | React アプリケーションで、React の最新のベストプラクティスに従っていることを確認するための ESLint プラグイン。 |
eslint-plugin-react-hooks | React フックに関する問題を検出するための ESLint プラグイン。 |
eslint-plugin-simple-import-sort | モジュールの読み込みの並び順に関する問題を検出するための ESLint プラグイン。 |
eslint-plugin-sort-keys-custom-order | オブジェクトプロパティの並び順に関する問題を検出するための ESLint プラグイン。 |
eslint-plugin-unused-imports | 未使用の変数や関数を検出するための ESLint プラグイン。 |
npm-run-all | 複数の npm スクリプトを同時に実行するためのツール。 |
prettier | コードフォーマッター。コードを一貫したスタイルで整形することができる。 |
3.5.4 .eslintrc.js の編集
.eslintrc.js にて ESLint の設定を行うことができます。
以下の通り eslintrc.js を編集します。
それぞれの概要についてはコメントに書いてあります。
さらに詳しく知りたい方は、ESLinkt の公式サイトや上記でインストールしたプラグインの GitHub にある ReadMe などを読んでみてください。
module.exports = {
root: true, // ルートディレクトリを指定
env: {
browser: true, // ブラウザのグローバル変数を有効化
es2022: true, // ES2022のグローバル変数を有効化
node: true, // Node.jsのグローバル変数を有効化
},
extends: [
"eslint:recommended", // ESLintの推奨ルールを有効化
"plugin:react/recommended", // Reactの推奨ルールを有効化
"plugin:@typescript-eslint/recommended", // TypeScriptの推奨ルールを有効化
"plugin:react-hooks/recommended", // React Hooksのルールを有効化
"plugin:jsx-a11y/recommended", // アクセシビリティ
"prettier", // Prettierとの競合を解消
],
overrides: [],
parser: "@typescript-eslint/parser", // TypeScriptを解析
parserOptions: {
ecmaVersion: "latest", // ESのバージョンを自動検出
sourceType: "module", // importを有効化
},
plugins: [
"sort-keys-custom-order", // オブジェクトのキーをソート
"react", // Reactのルールを有効化
"@typescript-eslint", // TypeScriptのルールを有効化
"simple-import-sort", // importをソート
"import", // importのルールを有効化
"unused-imports", // 未使用のimportをエラー
],
rules: {
"react/jsx-uses-react": "off", // Reactを使用していることを明示
"react/react-in-jsx-scope": "off", // Reactを使用していることを明示
"sort-keys-custom-order/object-keys": [
// オブジェクトのキーをソート
"error",
{ orderedKeys: ["id", "name", "title"] },
],
"sort-keys-custom-order/type-keys": [
// オブジェクトのキーをソート
"error",
{ orderedKeys: ["id", "name", "title"] },
],
"simple-import-sort/imports": "error", // importをソート
"simple-import-sort/exports": "error", // exportをソート
"import/first": "error", // importはファイルの先頭
"import/newline-after-import": "error", // import後に改行
"import/no-duplicates": "error", // 同じファイルのimportをマージ
"unused-imports/no-unused-imports": "error", // 未使用のimportをエラー
"react/prop-types": "off", // prop-typesを無効化
"no-undef": "error", // 未定義の変数をエラー
"no-var": "error", // varをエラー
},
settings: {
react: {
// Reactのバージョンを自動検出
version: "detect",
},
},
};
3.5.5 ESLint と Prettier の実行用スクリプトを用意
ESLint と Prettier を走らせるコマンドを用意しておきます。
package.json の script オブジェクト内に"入力コマンド":"実際に走るコマンド"
とすることで登録できます。
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
+ "lint": "run-p --continue-on-error lint:*",
+ "lint:eslint": "eslint --ext .tsx,.ts .",
+ "lint:prettier": "prettier --check .",
+ "fix": "run-s --continue-on-error fix:eslint fix:prettier",
+ "fix:eslint": "eslint --ext .tsx,.ts . --fix",
+ "fix:prettier": "prettier --write ."
},
内容は以下の通りとなります。
npm run lint
などのコマンドでターミナルから実行できます。
script | 実行する内容 |
---|---|
lint | "lint:eslint"と"lint:prettier"を並列に実行する。その際、スクリプト内でエラースローされても後続のスクリプトを実行する。 |
lint:eslint | 拡張子が.tsx と.ts のファイルに対して ESLint のチェックを実行する。 |
lint:prettier | Prettier のチェックを実行する |
fix | "fix:eslint"と"fix:prettier"を順番に実行する。その際、スクリプト内でエラーをスローされても後続のスクリプトを実行する。 |
fix:eslint | 拡張子が.tsx と.ts のファイルに対して ESLint を基準にコードを修正する。 |
fix:prettier | Prettier を基準にコードを整形する。 |
例えば、npm run fix
というようにターミナルから実行すると、自動で修正可能な ESLint のエラーを修正した後に Prettier によるコードの生計を行うことができます。
3.6 補足
この時点でのpackage.json
を以下に記載しておきます。
よければ参考にしてください。
package.json
{
"name": "just-do-it",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.24",
"@types/react": "^18.0.37",
"@types/react-dom": "^18.0.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "run-p --continue-on-error lint:*",
"lint:eslint": "eslint --ext .tsx,.ts .",
"lint:prettier": "prettier --check .",
"fix": "run-s --continue-on-error fix:eslint fix:prettier",
"fix:eslint": "eslint --ext .tsx,.ts . --fix",
"fix:prettier": "prettier --write ."
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.59.0",
"@typescript-eslint/parser": "^5.59.0",
"eslint": "^8.39.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-sort-keys-custom-order": "^1.0.5",
"eslint-plugin-unused-imports": "^2.0.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.7"
}
}
3.6 Tailwind CSS
3.6.1 Tailwind CSS とは
Tailwind CSS とは、便利な CSS のクラスがパックされた CSS フレームワークです。
BootStrap みたいなイメージを持っていただくとわかりやすいかもしれません。
Tailwind CSS は JavaScript に依存せず、使用していないクラスは本番用にビルドする時に削除されます。
そのため最終的なバンドルサイズを小さく保つことができます。
以下の公式のトップページがわかりやすく Tailwind CSS の特徴を述べていますのでぜひ見てみてください。
3.6.2 インストール
npm install -D tailwindcss
-D
は--save-dev
の省略記法です。
上記のコマンドで言えば、
npm install --save-dev tailwindcss
と同じ意味です。
3.6.3 tailwind.config.js
ファイルを生成
npx tailwindcss init
プロジェクトの直下にtailwind.config.js
という設定ファイルが生成されていますので、以下の通り追記します。
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
};
3.6.4 index.css に Tailwind ディレクティブを追加
Tailwind CSS を使用可能にするために、index.css に追記します。
@tailwind base;
@tailwind components;
@tailwind utilities;
3.6.5 prettier-plugin-tailwindcss をインストール
prettier-plugin-tailwindcss をインストールすることによってタグ内に記述した Tailswind CSS のクラス名を自動で並べ替えてくれます。
慣れてくるとどのクラス名がどこにあるのかすぐに探せるようになるため開発効率がグッと上がります。
npm install -D prettier-plugin-tailwindcss
3.6.6 動作確認
試しに、Tailwind CSS がうまく効いているか確認します。
function App() {
+ return <div className="text-sky-500">Hello, world!{}</div>;
}
export default App;
className
属性にtext-sky-500
と指定しています。
これは Tailwind CSS で用意されているクラスで、このクラスが指定された要素には以下のようなプロパティが当たっています。
.text-sky-500 {
--tw-text-opacity: 1;
color: rgb(14 165 233 / var(--tw-text-opacity));
}
Tailwind で文字色を指定するクラスはtext-色名-数値
みたいな感じで用意されています。
具体的には以下のような色が用意されています。
Tailwind CSS のおかげで色について悩みずぎることが減りました。(20 代 男性 会社員)
https://tailwindcss.com/docs/text-color
本題に戻ります。
プロジェクトをnpm start
で起動してlocalhost:3000
にアクセスしましょう。
以下のような表示になっていれば Tailwind が適用されています。
3.7 Firebase
Firebase
は Google が提供するモバイルおよび Web アプリケーションのためのクラウドベースのプラットフォームです。
データベースや認証、ストレージその他いろいろな機能が提供されています。
今回はこの Firebase のCloud Firestore
という NoSQL 型のデータベースとAuthentication
という認証サービス、そしてHosting
というデプロイに必要なサービスを使用します。
3.7.1 プロジェクトの作成
まずFirebase のトップ画面にアクセスします。
右上のコンソールへ移動
をクリックします。
プロジェクトを作成
ボタンをクリックします。
プロジェクト名(好きなように変えて問題ありません)を入力して続行
をクリックします。
Google アナリティクスはどちらでも良いですが、今回は特に使用する予定がないのでオフにしてプロジェクトを作成
をクリックします。
ローディングアイコンが現れます。
新しいプロジェクトの準備ができました
となったら続行
をクリックします。
無事にプロジェクトの作に成功すると下記のような画面に遷移します。
プロジェクト名の横に書いてあるSpark
とは今回使用する無料プランの名前です。
それなりに制限がありますが、今回のデモアプリであれば Spark プランで問題無いのでこのまま進めます。
ご自身のアプリの必要に応じて従量課金制の B l aze プランへの切り替えなど検討してください。
3.7.2 Authentication の設定
先ほどのプロジェクトのホーム画面からAuthentication
をクリックします。
Authentication の画面に遷移したら、始める
をクリックします。
様々な認証方法が用意されています。
その中にあるGoogle
をクリックします。
プロジェクト公開名は自動で入力されています。
プロジェクトのサポートメールというところから Firebase に登録しているメールアドレスを選択します。
保存
をクリックします。
ログインプロバイダに Google が追加されていれば成功です。
3.7.2 Cloud Firestore の設定
プロジェクトのトップ画面に戻り、Cloud Firestore
をクリックします。
Cloud Firestore の画面に遷移したら、データベースの作成
をクリックします。
セットアップモーダルが表示されるので、本番環境モードで開始する
が選択されていることを確認して次へ
をクリックします。
リソースロケーションを選択します。
asia-northeast1(Tokyo)
を選択して有効にする
をクリック。
3.7.3 リソースロケーションの設定
プロジェクトのホーム画面に戻り、サイドバーの歯車アイコンをクリックします。
プロジェクトの設定
をクリックします。
デフォルトのGCPリソースロケーションの鉛筆マークをクリックします。
先ほど設定したロケーションがすでに入力されているので、そのまま保存
を押すと下記のように表示されます。
3.7.4 プロジェクトにから Firebase を呼び出す。
プロジェクトのホーム画面に戻り、今度は画面中央あたりにある</>
マークをクリックします。(ホバーすると"Web"と出てきます)
アプリのニックネームをつけます。
アプリを登録
をクリックします。
:::note
本稿では Firebase Hosting は後で後で設定するので一旦飛ばします。
続いて FirebaseSDK の追加に書いてある説明の通りに進めていきます、
firebase をインストールします。
npm install firebase
VSCode を開いて以下のファイルを新規作成します。
ちなみに firebaseConfig のところには表示されていた情報をそのまま貼り付けて問題ありません。
「え、API キーなんてコードに直接貼り付けていいの?」と思われるかもしれません。
しかし、以下の構成オブジェクトはクライアントサイドに持たせて、どの Firebase プロジェクトにアクセスするか識別させるためのものです。
つまり公開前提の情報であり、この情報がなければ Firebase にアクセスすることができません。
しかしここで注意があります。
この構成要素はこの後に説明するセキュリティルールや Google Cloud Platform(GCP)コンソールでの API 制限などが適切に設定されていないと、悪意のある第 3 者から不正にプロジェクトを利用されたり、情報を読み取られる恐れがあります。
セキュリティルール等の設定を行うまでは Git などに上げないことを強くお勧めします。
import { initializeApp } from "firebase/app";
import { getAuth, GoogleAuthProvider } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: "書いてあったapiKey",
appId: "書いてあったappId",
authDomain: "書いてあったauthDmain",
messagingSenderId: "書いてあったmessagingSenderId",
projectId: "書いてあったprojectId",
storageBucket: "書いてあったstrageBucket",
};
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const provider = new GoogleAuthProvider();
const db = getFirestore(app);
export { auth, db, provider };
3.8 その他に必要なパッケージをインストール
npm install react-router-dom react-icons @tremor/react react-hooks-use-modal
パッケージ名 | 説明 |
---|---|
react-router-dom | React Router というルーティングライブラリ |
react-icons | アイコン |
@tremor/react | Tailwind CSS ベースの UI コンポーネントライブラリ。今回はチャートをメインに使用 |
react-hooks-use-modal | モーダルを超簡単に実装するための hooks |
環境構築は以上です。お疲れ様でした 🎉
次のセクションからいよいよ実装に入っていきます。
4.ページヘッダーを実装
本アプリのページ遷移は主にページヘッダーから行なっていきます。
そのため、まずはページヘッダーを書かないことには始まりません。
まずは index.tsx にルーティングの大元を記述します。
import "./index.css";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
上記はルーティングを設定していく App コンポーネントをreact-router-dom
からインポートしたBrowserRouter
コンポーネントでラップしています。
import React from "react";
import { Link, Route, Routes } from "react-router-dom";
import { Layout } from "./routes/Layout";
const App = () => {
return (
<Routes>
<Route path="/" element={<Layout />}></Route>
</Routes>
);
};
const NoMatch = () => {
return (
<div>
<h2>ページが見つかりませんでした。</h2>
<p>
<Link to="/">ホームに戻る</Link>
</p>
</div>
);
}
export default App;
Routes
コンポーネントの中に、Route
コンポーネントを記述していくことで、ルーティングを実現していきます。
Route
コンポーネントにはpath
とelement
という 2 つのプロパティを渡す必要があります。
path
には対応させたいパスを記述し、element
には path で指定した URL にアクセスした時に呼び出したいコンポーネントを指定します。
上記では、https://xxx.com/
みたいなアプリケーションのルート URL にアクセスした際に Layout コンポーネントが表示されることを示しています。
開発環境では、http://localhost:3000/
が対応する URL となります。
続いて Layout コンポーネントを作成していきます。
import { Outlet } from "react-router-dom";
import { Header } from "../components/Header";
export const Layout = () => {
return (
<div>
<Header />
<div className="fixed h-full w-full overflow-auto bg-gray-200 pb-52 pt-5 ">
<Outlet />
</div>
</div>
);
};
今回のアプリではLayout
コンポーネントを常に呼び出し、Layout コンポーネントから他のコンポーネントを呼び出すことでページの切り替えを実現していきます。
ページヘッダーであるHeader
コンポーネントは常に呼び出すため、固定で配置しています。
では、どのようにページを切り替えるかというと、<Outlet/>
という記述がポイントです。
先ほどApp
コンポーネントで記述したRouter
コンポーネントはネストすることが可能です。
その際、複数のルートをネストさせることで、親ルートから子ルートを<Outlet />
で呼び出すことができます。リクエストされた URL に応じて子ルートのみ表示が切り替わります。
後ほど具体的に記載していくのでその時に改めて解説します。
続けて Header コンポーネントを作成していきます。
import { AiOutlineAreaChart, AiOutlineHome } from "react-icons/ai";
import { BsPencil } from "react-icons/bs";
import { GoSignIn } from "react-icons/go";
import { Link, NavLink } from "react-router-dom";
export const Header = () => {
return (
<header className="h-20 select-none">
<div className="container mx-auto flex h-20 flex-wrap items-center overflow-hidden font-medium lg:justify-center">
<div className="flex h-full w-full items-center justify-around">
<nav className="flex w-full justify-around">
<Link to="/" className="inline-block py-4 md:py-0">
<span className="p-1 text-xl font-black leading-none text-gray-900">
JustDoIt
</span>
</Link>
<NavLink
to={"/"}
className={({ isActive, isPending }) =>
isPending || isActive
? "flex items-center text-indigo-600"
: "flex items-center hover:text-indigo-400"
}
>
<AiOutlineHome className="inline-block" size={20} />
<span className="hidden pl-2 md:inline-block">ホーム</span>
</NavLink>
<NavLink
to="/record-study"
className={({ isActive, isPending }) =>
isPending || isActive
? "flex items-center text-indigo-600"
: "flex items-center hover:text-indigo-400"
}
>
<BsPencil className="inline-block" size={20} />
<span className="hidden pl-2 md:inline-block">記録</span>
</NavLink>
<NavLink
to="/my-page"
className={({ isActive, isPending }) =>
isPending || isActive
? "flex items-center text-indigo-600"
: "flex items-center hover:text-indigo-400"
}
>
<AiOutlineAreaChart className="inline-block" size={20} />
<span className="hidden pl-2 md:inline-block">マイページ</span>
</NavLink>
<NavLink
to="/login"
className={({ isActive, isPending }) =>
isPending || isActive
? "flex items-center text-indigo-600"
: "flex items-center hover:text-indigo-400"
}
>
<GoSignIn className="inline-block" size={20} />
<span className="hidden pl-2 md:inline-block">ログイン</span>
</NavLink>
</nav>
</div>
</div>
</header>
);
};
nav
タグの中にいくつかリンクを作成しています。
React Router によるアプリケーション内のルーティングではa
タグではなくLink
コンポーネントを使用します。a
タグも使えないわけではありませんが、a
タグによるページ遷移ではリクエストされたページを丸ごと再読み込みすることになります。(ブラウザのアドレスバーの横にある読み込みマークが一旦「×」になる状態です)
しかし、切り替えが不要なものは極力再描画させたくありません。そこでLink
コンポーネントによるページの切り替えを行います。
Link
コンポーネントを使用すれば、Route コンポーネントで設定したパスに対応するコンポーネントのみ切り替えを行ってくれるため、ページ全体を再読み込みしません。そのため、基本的にアプリケーション内のページ遷移はLink
コンポーネントを使用します。
<Link to="遷移先のパス">リンク文字列<Link/>
また、ナビゲーションの中で Link と似ているNavLink
コンポーネントを呼び出しています。
このNavLink
コンポーネントはリンクがactive
(そのリンクに対応するページに自分がいる)かpending
(そのリンクに対して遷移中)かを認識します。
後ほど子ページを実装した際に分かりますが、上記の実装では自分が今いる URL が NavLink のto
に対応している、もしくは遷移中であれば該当のリンク文字列を indigo 色になるよう条件分岐をしています。
isAcrive
およびisPending
という値を NavLink から受け取り、className 属性に渡す文字列を分岐しています。これらの条件に満たないリンクは hover した時だけ薄めの indigo にするようにしています。
ローカルサーバを立ち上げてhttp://localhost:3000
にアクセスしてみましょう。
ホームにはto="/"
と指定しているため、現在地と認識されて文字色が indigo になっています。
さて、もう一つヘッダーを実装する上で重要なことを解説します。
ページ幅を小さくするとメニューから文字列が消えてアイコンだけになることにお気づきでしょうか。
このレスポンシブ対応は Tailwind のmd:
という記述で実現しています。
実際に該当するコード例は以下の部分です。
<AiOutlineHome className="inline-block" size={20} />
<span className="hidden pl-2 md:inline-block">ホーム</span>
最初に補足として、AiOutlineHome
というのは React Icons からインポートしたコンポーネントです。このコンポーネントを置くことによりアイコンを表示させています。
本題ですが、span タグの className にhidden
と指定しています。このクラスにはdisplay: none;
が割り当てられており、文字列を非表示にしています。
そこでmd:
の登場です。このmd:
の直後にクラス名を指定すると、画面幅が 768px 以上になった時にそのクラスが適用されます。つまり上記の実装では、デフォルトでは文字列は表示しない(display: none;)が、画面幅が 768px を超えた時に表示(display: inline-block)しています。
他にも画面幅のブレークポイントを貼る便利なクラスやあるので興味がある方は公式ドキュメントを参照してみてください。
5. ヘッダーのナビゲーションに対応するルーティングを設定
ルーティングの設定をしていくために、ページのコンポーネントを新規作成して仮実装します。
import React from "react";
export const Home = () => {
return <div>Home</div>;
};
import React from "react";
export const Home = () => {
return <div>Home</div>;
};
import React from "react";
export const RecordStudy = () => {
return <div>RecordStudy</div>;
};
import React from "react";
export const MyPage = () => {
return <div>Mypage</div>;
};
import React from "react";
export const SignIn = () => {
return <div>SignIn</div>;
};
上記は特に何の変哲もない div タグたちです。
続けて App コンポーネントでルーティングを設定します。
import React from "react";
import { Link, Route, Routes } from "react-router-dom";
import { Home } from "./routes/Home";
import { Layout } from "./routes/Layout";
+ import { MyPage } from "./routes/MyPage";
+ import { RecordStudy } from "./routes/RecordStudy";
+ import { SignIn } from "./routes/SignIn";
const App = () => {
return (
<Routes>
<Route path="/" element={<Layout />}>
+ <Route index element={<Home />} />
+ <Route path="record-study" element={<RecordStudy />} />
+ <Route path="my-page" element={<MyPage />} />
+ <Route path="login" element={<SignIn />} />
+ <Route path="*" element={<NoMatch />} />
</Route>
</Routes>
);
};
const NoMatch = () => {
return (
<div>
<h2>ページが見つかりませんでした。</h2>
<p>
<Link to="/">ホームに戻る</Link>
</p>
</div>
);
}
export default App;
最初に記述した Layoute コンポーネントを呼び出すの Route にネストさせています。
こうすることによって Layout コンポーネントの中でOutlet
としてそれぞれ子ルートのみ切り替えることができます。つまり、Layout は表示されたままなので、Layout の中で固定で呼び出されている Header も表示されたままということです。
基本的には前述で示した Router と同じ書き方なのですが、若干書き方が異なっている箇所について解説します。
1 つ目に、index
です。
親ルートが呼び出された時にデフォルトで呼び出したい子ルートにindex
と指定してあげます。
そのため上記のコードでは、'localhost:3000/'にアクセスした時の Home コンポーネントが表示されることになります。
2 つ目にルーティングを設定していない URL を設定した場合の処理です。
例えばlocalhost:3000/sonzaishinaipath
のように存在しないパスをリクエストされた場合、ページが存在しないことを示してあげるのが親切です。
そこで、path="*"
とすることでルーティングに一致しない URL に対してコンポーネントを割り当てます。
割り当てるコンポーネントに、前述のNoMatch
コンポーネントを割り当てることで ページがないことを伝えています。
動作確認をします。
ヘッダーのナビゲーションをクリックすると各ページに遷移します。
また URL が変化するたびにヘッダーの文字列色が変化していることにも着目してください。
6. 各コンポーネントをハードコーディング
続いて各ページの UI を固めていきましょう。
6.1 今回よく使う型を定義
各コンポーネントで毎回型を定義してもよいのですが、それだと少し面倒なのでよく使う型だけ型定義ファイルにまとめて使いまわせるようにします。
export type StudyLog = {
id: string;
title: string;
author: {
id: string;
photoUrl: string;
username: string;
};
createdAt: string;
detail: string;
hour: string;
minute: string;
time: number;
};
上記で定義した型は主に Firebase からデータを取得する時に使います。
6.3 Tailwind CSS を上書き
@tailwind base;
@tailwind components;
@tailwind utilities;
+ /* number型のinput要素のスピンボタンを非表示 */
+ @layer base {
+ input[type="number"]::-webkit-outer-spin-button,
+ input[type="number"]::-webkit-inner-spin-button,
+ input[type="number"] {
+ -webkit-appearance: none;
+ margin: 0;
+ -moz-appearance: textfield !important;
+ }
+ }
+
+ /* スクロールバーを非表示にする */
+ @layer utilities {
+ .hidden-scrollbar {
+ -ms-overflow-style: none; /* IE, Edge 対応 */
+ scrollbar-width: none; /* Firefox 対応 */
+ }
+ .hidden-scrollbar::-webkit-scrollbar {
+ /* Chrome, Safari 対応 */
+ display: none;
+ }
+ }
この後実装するフォームの input 要素でtype="number"
を使用するのですが、その時のスピンボタンを非表示にする設定と、スクロールバーを非表示にするクラスを用意します。
上記は次の 2 つの記事を参考にさせていただきました。🙏
詳細は記事で説明されているので読んでみてください。
6.2 仮のデータで UI の構築
ホーム画面で表示する投稿一つ一つを表示させる Card コンポーネントを作成します。
具体的にはこんな形のカードを作っています。
※まだ Home コンポーネントで呼び出していないので表示されません。
import React, { FC } from "react";
import { useModal } from "react-hooks-use-modal";
import { AiOutlineClose } from "react-icons/ai";
import { BsBook } from "react-icons/bs";
import { StudyLog } from "../types/types";
type CardProps = {
cardInfo: StudyLog;
};
export const Card: FC<CardProps> = ({ cardInfo }) => {
const [Modal, open, close] = useModal("root", {});
return (
<>
<article className="md:w-100 h-52 w-96 p-3">
<div className="flex h-full w-full rounded-lg bg-white p-3 shadow-md">
<div className="flex w-1/5 flex-col items-center justify-between">
<div className="flex h-2/5 items-center justify-center">
<div className="items-between flex h-12 w-12 justify-center overflow-hidden">
<img
className=" h-full w-full rounded-full border-[1px]"
alt="アイコン"
src={cardInfo.author.photoUrl}
/>
</div>
</div>
<button className="h-2/5" onClick={open}>
<BsBook className="text-sky-500" size={"2rem"} />
</button>
<div className="h-1/5"></div>
</div>
<div className="w-full divide-y divide-sky-200 pl-2">
<div className="flex h-2/5 items-center px-2">
<h3 className="text-xl text-gray-600">{cardInfo.title}</h3>
</div>
<div className="flex h-2/5 items-center px-2">
<p className=" text-3xl text-sky-500">
{cardInfo.hour}
<span className="px-1 text-xl">時間</span>
{cardInfo.minute}
<span className="px-1 text-xl">分</span>
</p>
</div>
<div className="h-1/5 px-2">
<p className="flex h-full items-center text-gray-400">
{cardInfo.author.username}
</p>
</div>
</div>
</div>
</article>
<Modal>
<div className=" h-[36rem] w-screen rounded-2xl bg-white p-10 md:w-[50rem]">
<div className="h-full w-full divide-y divide-sky-200 overflow-auto">
<h2 className="py-2 text-center text-3xl">{cardInfo.title}</h2>
<p className="py-2 text-center text-3xl text-sky-500">
{cardInfo.hour}時間{cardInfo.minute}分
</p>
{cardInfo.detail && <p className="py-2">{cardInfo.detail}</p>}
<div className="relative flex items-center py-2">
<p className="w-full py-2 text-center text-gray-600">
{cardInfo.author.username}
</p>
<button
className="absolute right-3 z-10 flex items-center rounded-lg border border-sky-800 p-1 text-sky-800 duration-300 hover:border-white hover:bg-sky-500 hover:text-white active:bg-sky-800"
onClick={close}
>
<span>閉じる</span>
<AiOutlineClose className="text-xl" />
</button>
</div>
</div>
</div>
</Modal>
</>
);
};
はじめに先ほどtypes.ts
で定義した StudyLog 型を import します。
CardProps という名前でこのコンポーネントので受け取る引数の型を定義してあげます。
今回は StudyLog 型だけあればいいので一旦 cardInfo という名前で StudyLog を受け取ります。
コンポーネント(Card)の型に FCを指定し、props で cardInfo を受け取ります。
この Card コンポーネントには大きく分けて 2 つの部品があります。
1 つ目がarticle
タグ内に書いたカードそのものです。直前のキャプチャが article タグで書いたものです。
単純に渡された StudyLog 型のデータからプロパティを指定して読み込んでいます。
2 つ目がモーダルです。これは環境構築時にインストールしたreact-hooks-use-modal
というモーダルを簡単に実装できる hooks ライブラリです。
この Modal コンポーネント内に記述した内容がモーダルの本体として表示されます。
使い方は、はじめにuseModal
というフックを呼び出します。この時、第一引数に"root"
を指定し、第二引数にオプションを記述するオブジェクトを渡します。今回は全てデフォルトのオプションにするので空オブジェクトを渡します。
useModal
からはModal
、open
、close
、isOpen
の 4 つ値が入った配列が返されます。
それぞれの概要は以下のとおりです。
要素 | 概要 |
---|---|
Modal | コンポーネント。モーダルとして表示させたい要素をラップする。 |
open | モーダルを開く関数 |
close | モーダルを閉じる関数 |
isOpen | モーダルが開いているか閉じているかの論理値。今回はこの変数を直接使用しないため記述を省略しています。 |
オプションなど詳細については公式リポジトリをご参照ください。
続いてHome
コンポーネントの UI を構築します。
import React, { useState } from "react";
import { Card } from "../components/Card";
import { StudyLog } from "../types/types";
const dummyData: StudyLog[] = [];
for (let i = 0; i < 100; i++) {
dummyData.push({
id: i.toString(),
title: `第${i}回React勉強会`,
author: {
id: i.toString(),
photoUrl: "https://via.placeholder.com/150/92c952",
username: "takumi",
},
createdAt: new Date().toString(),
detail: "Reactの勉強をしました",
hour: "1",
minute: "30",
time: 90,
});
}
export const Home = () => {
const [studyLog, setStudyLog] = useState<StudyLog[]>(dummyData);
return (
<div className="hidden-scrollbar flex w-screen justify-center overflow-hidden">
<div className="hidden-scrollbar container flex h-full flex-col items-center overflow-scroll pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
<div className="container flex flex-col items-center pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
{studyLog.map((card) => (
<Card key={card.id} cardInfo={card} />
))}
</div>
</div>
</div>
);
};
はじめに dummyData という変数名で StudyLog 配列型のダミーを用意しています。
後に Firebase から値を取得する際も同様のデータ形式に変換します。
ダミーデータは所詮一時的なものなのでこの辺にして、Home コンポーネントの中身について解説します。
コンポーネント一番上でuseState()
を使用してステート(状態)を定義しています。
useState は React の Hooks(複雑な処理を裏側に任せることができる関数のようなものだと思ってください)の 1 つです。構文は以下のとおりです。
const [状態変数, 更新関数] = useState(初期状態);
useState は 0 番目に状態変数、1 番目に状態変数を更新するための関数を戻します。その値を配列に分割代入して定義します。
useState の引数には初期値を渡します。上記では一旦初期値をダミーデータにしています。
また useState で管理する状態変数の型ですが、型推論(useState("")
と初期値に文字列を渡すとその状態変数は string 型とみなされる)が使える時は型推論に任せてしまっても構いませんし、Home コンポーネントのようにuseState<型>()
とジェネリクスに型を渡しても宣言可能です。
注意点として useState はコンポーネントの最上部で宣言する必要があります。
続いて MyPage のを UI を作成していきます。
import { AreaChart, Metric } from "@tremor/react";
import { useState } from "react";
type Log = {
date: string;
"": number;
};
const timeFormatter = (time: number) => {
return `${Math.floor(time / 60)}時間${time % 60}分`;
};
const dateFormatter = (date: Date) => {
return `${date.getMonth() + 1}/${date.getDate()}`;
};
const dummyData: Log[] = [];
for (let i = 0; i < 30; i++) {
dummyData.push({
date: dateFormatter(new Date(2022, 8, i)),
"": Math.floor(Math.random() * 100),
});
}
export const MyPage = () => {
const [chartData, setChartData] = useState<Log[] | null>(dummyData);
return (
<div className="hidden-scrollbar flex w-screen justify-center overflow-hidden">
<div className="hidden-scrollbar container flex flex-col items-center overflow-scroll rounded-3xl bg-white pt-4 drop-shadow-md md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
<div className=" container flex h-80 flex-col items-center pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
<Metric className=" text-gray-600">学習チャート</Metric>
{chartData && (
<AreaChart
className="h-72 px-5 py-10"
data={chartData}
index={"date"}
categories={[""]}
colors={["indigo"]}
valueFormatter={timeFormatter}
showLegend={false}
showTooltip={true}
yAxisWidth={80}
></AreaChart>
)}
</div>
</div>
</div>
);
};
ここでもダミーデータを作成してから UI を構築しています。
同様にダミーデータは後で消すので深く考えなくて OK です。
ようやくtremor
という Trailwind CSS 製の UI コンポーネントライブラリの登場です。
上記の画像のようにおしゃれないい感じのエリアチャートを描画してくれます。
ざっくりと使い方を解説します。上記でチャートに使用しているコンポーネントはAreaChart
です。
プロパティをいくつか指定するできます。上記で設定しているプロパティは以下のとおりです。
使用したプロパティ | やっていること |
---|---|
data={chartData} | データを指定します。コンポーネントの最上部で定義した chartData というオブジェクトの配列を渡しています。それぞれのオブジェクトには x 軸と y 軸に対応させる値が必要です。 |
index={"date"} | x 軸に使用するデータのキーを指定します。 |
categories={[""]} | y 軸に使用するデータのキーを指定します。これはチャートをホバーするとでてくるツールチップにも文字列としてでてくるのですが、キーをtime みたいな文字列にするとなぜか1時間30分time と表記されてしまいうまくいかなかったので渋々から文字列をキーとして定義しています。本当はあまり良くないので他に方法があったら修正してください。 |
colors={["indigo"]} | チャートの色を指定します。 |
valueFormatter={timeFormatter} | データ文字列のフォーマッタを指定します。ただの数値として入ってきた学習時間の情報をh時間M分 という形式の文字列に変換するように定義した関数を渡しています。 |
showLegend={false} | 凡例を非表示にしています。 |
showTooltip={true} | ツールチップを非表示にします |
yAxisWidth={80} | y 軸のラベルの幅を指定しています。 |
続いて投稿画面の UI を記述していきます。
import { Metric } from "@tremor/react";
import React, { ChangeEventHandler, useCallback, useState } from "react";
export const RecordStudy = () => {
const [title, setTitle] = useState("");
const [detail, setDetail] = useState("");
const [hour, setHour] = useState("");
const [minute, setMinute] = useState("");
const [time, setTime] = useState(0);
const handleHour: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
if (
parseInt(e.target.value) > parseInt(e.target.max) ||
e.target.value.length > 2
) {
setHour("");
setTime(parseInt(minute));
return;
}
setHour(e.target.value.trim());
setTime(
parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim()) *
60 +
parseInt(minute === "" ? "0" : minute)
);
},
[minute]
);
const handleMinute: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
if (
parseInt(e.target.value) > parseInt(e.target.max) ||
e.target.value.length > 2
) {
setMinute("");
setTime(parseInt(hour) * 60);
return;
}
setMinute(e.target.value.trim());
setTime(
parseInt(hour === "" ? "0" : hour) * 60 +
parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim())
);
},
[hour]
);
return (
<div className="flex w-full justify-center">
<form className="w-full rounded-3xl bg-white p-5 drop-shadow-md md:w-[50rem]">
<div className="mb-6">
<Metric className=" text-center text-gray-600">学習を記録</Metric>
<label
htmlFor="title"
className=" mb-2 block text-sm font-medium text-gray-900"
>
学習内容
</label>
<input
id="title"
type="text"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="例)数学A 問題集"
required
onChange={(e) => setTitle(e.target.value)}
value={title}
/>
</div>
<div className="mb-6">
<label
htmlFor="hour"
className="mb-2 block text-sm font-medium text-gray-900"
>
学習時間
<span className="px-1 text-gray-500">(時間:分)</span>
</label>
<div className="block h-10 w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 text-sm text-gray-900 shadow-sm">
<div className="flex h-full w-full items-center">
<input
type="number"
inputMode="numeric"
id="hour"
min="0"
max="24"
placeholder="00"
className="w-8 bg-gray-50 text-center outline-none focus:bg-sky-100"
onChange={handleHour}
value={hour}
/>
<span className="px-1">:</span>
<input
type="number"
inputMode="numeric"
id="minute"
min="0"
max="99"
placeholder="00"
className="w-8 bg-gray-50 text-center outline-none focus:bg-sky-100"
onChange={handleMinute}
value={minute}
/>
</div>
</div>
</div>
<div className="mb-6 ">
<label
htmlFor="detail"
className="mb-2 block text-sm font-medium text-gray-900"
>
メモ
</label>
<textarea
id="detail"
className="block h-40 w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500"
onChange={(e) => setDetail(e.target.value)}
value={detail}
></textarea>
</div>
<div className="flex w-full justify-center py-5">
<button
type="submit"
className="w-full rounded-lg border border-sky-800 px-10 py-2.5 text-center text-sm font-medium text-sky-800 duration-300 hover:border-white hover:bg-sky-500 hover:text-white active:bg-sky-800"
>
記録する
</button>
</div>
</form>
</div>
);
};
input 要素に記述された情報は useState を使用して状態を管理します。そのため、各要素に対応するステートを定義します。
title
とdetail
については見ての通りただの文字列の状態を管理しているだけなので問題ないかと思うのですが、hour
,minute
,time
という 3 つのステートで記録時間を管理しているところを不思議に思われた方もいるかもしれません。
hour は時間を管理し、minut は分、time はその両方を合わせた分で管理するようにしています。
理由としては例えばユーザーによっては分
で学習時間を測っていて 90 分として記録したいとします。しかしホーム画面では時間の表記を統一したいため、90分
と記録されてもホーム画面では1時間30分
と表示させたいです。
そこで、別途time
という状態変数を用意し、hour
とminute
を合計した分単位の値として保持します。
続いて、hour
とminute
の状態を更新する関数handleHour
とhandleMinute
について説明します。処理自体はほぼ一緒なので handleHour のみ解説します。
まず、handleHour のメインの処理についてです。
e
という引数を受け取っていますが、これは input 要素に変更を加えた(つまり、文字を入力・削除した)ときのイベントを受け取っています。関数の代入先にはChangeEventHandler<HTMLInputElement>
という型を指定してください。
以降の処理をざっくりとコメントで説明します。
(e) => {
if (
// もしinputに入力された値が、inputタグに設定したmax以上の数値
parseInt(e.target.value) > parseInt(e.target.max) ||
// もしくは3桁以上入力されたら
e.target.value.length > 2
) {
// input欄をリセットして
setHour("");
// timeにminuteのみ代入
setTime(parseInt(minute));
// 以降の処理を行わずに関数を終了
return;
}
// hourをinput欄の値で更新
setHour(e.target.value.trim());
// 時単位の値を分単位に修正して時分を合計してtimeに代入
// その際、input欄が空の場合は0に変換する
setTime(
parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim()) * 60 +
parseInt(minute === "" ? "0" : minute)
);
},
さて、上記の処理はuseCallback
という React のフックを使って記述しています。
hour の input 欄が変更された時にこの関数を実行するだけでなく、minute の input 欄が変更された時にもこの関数を実行します。第二引数の配列は依存配列と呼ばれ、この配列に指定した変数が変更された時にのみ関数を実行するようになります。
個人的に useCallback を活かしきれていない気がするのと、handleHour と handleMinute の処理に重複が多く冗長な記述になっているため時間がある時にリファクタしたい...。
useCallbak とは React のフックの一つで、関数をメモ化するためのものです。メモ化とは、関数の引数と戻り値をキャッシュしておき、同じ引数で呼び出された場合はキャッシュされた戻り値を返すことで、関数の実行を高速化する手法です。
公式ドキュメントで詳細を確認することをおすすめします。
続いてサインイン画面の UI を実装します。
import { FcGoogle } from "react-icons/fc";
export const SignIn = () => {
return (
<div className=" flex w-screen justify-center p-5 ">
<div className="h-96 w-full max-w-xl rounded-2xl bg-white p-5 px-10">
<h2 className=" pb-4 text-center text-2xl text-gray-600">
ログイン方法を選択
</h2>
<ul>
<li className="flex justify-center">
<button className="flex items-center rounded-lg border border-sky-800 px-10 py-2.5 text-center text-lg font-medium text-sky-800 duration-300 hover:border-white hover:bg-sky-500 hover:text-white active:bg-sky-800">
<FcGoogle className="mr-1" />
Googleアカウント
</button>
</li>
</ul>
</div>
</div>
);
};
特別なことはしていないので一旦解説は飛ばします。
ちなみにソースコードでコンポーネント名はSignIn
としているのに表示と URL はlogin
及びログイン
としているのは個人的な好みです。
新規登録ロジックを実装したくなった時、Registration, login, logout よりも SignUp, SignIn, SignOut の方がしっくりくるということ、そしてユーザー的にはログインという言葉の方が馴染みがありそうなのでこのような実装にしています。
7 ログインを実装
7.1 ログイン状態で表示切り替え
現状だとログインしてもしなくても常に同じ UI が返されます。
ログインしたのにログインボタンが表示されたり、ログアウトしているのに投稿画面が表示されては困りますから、
ログインの実装とログイン状態による UI の切り替えを実装しましょう。
まずは App コンポーネントで認証情報の取得とログイン状態を定義しましょう。
+ import type { User } from "@firebase/auth";
+ import { getAuth, onAuthStateChanged } from "firebase/auth";
+ import React, { useEffect, useState } from "react";
import { Link, Route, Routes } from "react-router-dom";
import { Home } from "./routes/Home";
import { Layout } from "./routes/Layout";
import { MyPage } from "./routes/MyPage";
import { RecordStudy } from "./routes/RecordStudy";
import { SignIn } from "./routes/SignIn";
const App = () => {
+ const [user, setUser] = useState<User | null>(null);
+ useEffect(() => {
+ return onAuthStateChanged(getAuth(), (user: User | null) => {
+ setUser(user);
+ });
+ }, []);
return (
<Routes>
+ <Route path="/" element={<Layout user={user} />}>
<Route index element={<Home />} />
+ <Route path="my-page" element={<MyPage user={user} />} />
+ <Route path="record-study" element={<RecordStudy user={user} />}></Route>
+ <Route path="/login" element={<SignIn user={user} />}></Route>
<Route path="*" element={<NoMatch />} />
</Route>
</Routes>
);
};
const NoMatch = () => {
return (
<div>
<h2>ページが見つかりませんでした。</h2>
<p>
<Link to="/">ホームに戻る</Link>
</p>
</div>
);
}
export default App;
最初にuseEffect
という React のフックで、コンポーネントのレンダリング後に実行される関数を第一引数として定義しています。また、第 2 引数は依存配列で、ここに指定した変数が変更された時に第一引数の関数が実行されます。ここでは、レンダリング後に 1 度だけ実行されれば良いため、空配列を指定しています。
onAuthStateChanged
は Firebase の認証情報が変更された時に実行される関数です。第一引数にはgetAuth()
で取得した認証情報を渡し、第二引数には認証情報が変更された時に実行される関数を渡します。ここでは、認証情報が変更された時にsetUser
関数を実行しています。これだけで認証情報がリッスンされるのめちゃくちゃ便利ですよね。すご。
そして user として定義したステートを各コンポーネントに props として渡しています。これで各コンポーネントでログイン状態を判定できるようになりました。
続いて SignIn コンポーネントを仕上げていきます。
+ import type { User } from "@firebase/auth";
+ import { signInWithPopup } from "firebase/auth";
+ import React, { FC, useEffect } from "react";
import { FcGoogle } from "react-icons/fc";
+ import { useNavigate } from "react-router-dom";
+
+ import { auth, provider } from "../firebase";
+
+ type SignInProps = {
+ user: User | null;
+ };
+
+ export const SignIn: FC<SignInProps> = ({ user }) => {
+ const navigate = useNavigate();
+ const loginWithGoogle = () => {
+ signInWithPopup(auth, provider).then(() => {
+ navigate("/");
+ });
+ };
+
+ useEffect(() => {
+ if (user) {
+ navigate("/");
+ }
+ }, [user, navigate]);
return (
<div className=" flex w-screen justify-center p-5 ">
<div className="h-96 w-full max-w-xl rounded-2xl bg-white p-5 px-10">
<h2 className=" pb-4 text-center text-2xl text-gray-600">ログイン方法を選択</h2>
<ul>
<li className="flex justify-center">
<button
className="flex items-center rounded-lg border border-sky-800 px-10 py-2.5 text-center text-lg font-medium text-sky-800 duration-300 hover:border-white hover:bg-sky-500 hover:text-white active:bg-sky-800"
+ onClick={loginWithGoogle}
>
<FcGoogle className="mr-1" />
Googleアカウント
</button>
</li>
</ul>
</div>
</div>
);
};
はじめに、App コンポーネントから渡された user を受け取るために型を定義します。
user というプロパティに Firebase で定義されている User 型を割り当てます。ログアウトしている状態では null なので null を許容しています。
続いて、SignIn コンポーネントに FC 型を指定し、ジェネリクスに型情報を渡してあげます。そうすることでコンポーネントの引数で user を受け取れるようになります。
続いてコンポーネントのトップでuseNavigate()
という React ReactRouter からインポートしたフックを navigate という関数に格納しています。
次にログイン処理を行う関数loginWidhGoogle
を宣言しています。
関数ないでは Firebase で用意されている SignInWithPopup というポップアップにより Google アカウントを選択するポップアップを出力しします。
第 1 引数には firebase.ts で getAuth()関数によって取得した auth を渡します。
第 2 引数には firebase.ts で GoogleAuthProvider インスタンスが格納した provider を渡します。
認証完了後の処理として、then()メソッドを呼び出し、その中で navigate 関数を呼び出しています。これでログイン後にホーム画面にリダイレクトされるようになりました。
認証処理自体はこれで完了なのですが、ログイン済みなのにも関わらず、手打ちでログイン/login
をリクエストした際にログイン画面が表示されっぱなしなのは好ましくありません。
そこで、useEffect フックを使ってログイン状態をリッスンし、ログイン済みならばホーム画面にリダイレクトする処理を実装します。
依存配列には user と navigate を渡しています。これで user と navigate の状態が変更された時に useEffect が実行されるようになります。(そのようなケースは考えにくいですが念の為ぐらいな感じです。)
続いて同じように Layout コンポーネントを仕上げます。
+ import type { User } from "@firebase/auth";
+ import React, { FC } from "react";
import { Outlet } from "react-router-dom";
import { Header } from "../components/Header";
+ type layoutProps = {
+ user: User | null;
+ };
+ export const Layout: FC<layoutProps> = ({ user }) => {
return (
<div>
+ <Header user={user} />
<div className="fixed h-full w-full overflow-auto bg-gray-200 pb-52 pt-5 ">
<Outlet />
</div>
</div>
);
};
処理としては SignIn コンポーネントと同じですので解説はスキップします。
続いて Header コンポーネントです。ここは認証状態によって UI を変更するので条件分岐が必要です。
+ import type { User } from "@firebase/auth";
+ import { signOut } from "firebase/auth";
+ import React, { FC } from "react";
import { AiOutlineAreaChart, AiOutlineHome } from "react-icons/ai";
import { BsPencil } from "react-icons/bs";
import { GoSignIn, GoSignOut } from "react-icons/go";
import { Link, NavLink, useNavigate } from "react-router-dom";
+ import { auth } from "../firebase";
+
+ type HeaderProps = {
+ user: User | null;
+ };
+
+ export const Header: FC<HeaderProps> = ({ user }) => {
+ const navigate = useNavigate();
+ const signOutGoogle = () => {
+ signOut(auth).then(() => {
+ navigate("/");
+ });
+ };
return (
<header className="h-20 select-none">
<div className="container mx-auto flex h-20 flex-wrap items-center overflow-hidden font-medium lg:justify-center">
<div className="flex h-full w-full items-center justify-around">
<nav className="flex w-full justify-around">
<Link to="/" className="inline-block py-4 md:py-0">
<span className="p-1 text-xl font-black leading-none text-gray-900">JustDoIt</span>
</Link>
<NavLink
to={"/"}
className={({ isActive, isPending }) =>
isPending || isActive
? "flex items-center text-indigo-600"
: "flex items-center hover:text-indigo-400"
}
>
<AiOutlineHome className="inline-block" size={20} />
<span className="hidden pl-2 md:inline-block">ホーム</span>
</NavLink>
+ {user ? (
+ <>
<NavLink
to="/record-study"
className={({ isActive, isPending }) =>
isPending || isActive
? "flex items-center text-indigo-600"
: "flex items-center hover:text-indigo-400"
}
>
<BsPencil className="inline-block" size={20} />
<span className="hidden pl-2 md:inline-block">記録</span>
</NavLink>
<NavLink
to="/my-page"
className={({ isActive, isPending }) =>
isPending || isActive
? "flex items-center text-indigo-600"
: "flex items-center hover:text-indigo-400"
}
>
<AiOutlineAreaChart className="inline-block" size={20} />
<span className="hidden pl-2 md:inline-block">マイページ</span>
</NavLink>
+ </>
+ ) : null}
+ {user ? (
+ <button onClick={signOutGoogle} className="flex items-center hover:text-indigo-400">
+ <GoSignOut className="inline-block" size={20} />
+ <span className="hidden pl-2 md:inline-block">ログアウト</span>
+ </button>
+ ) : (
<NavLink
to="/login"
className={({ isActive, isPending }) =>
isPending || isActive
? "flex items-center text-indigo-600"
: "flex items-center hover:text-indigo-400"
}
>
<GoSignIn className="inline-block" size={20} />
<span className="hidden pl-2 md:inline-block">ログイン</span>
</NavLink>
+ )}
</nav>
</div>
</div>
</header>
);
};
分岐は三項演算子で{{user ? (ログイン時の要素) : null}}
のように記述しています。
つまり、ログイン状態の時のみ表示する要素は記録
、マイページ
、ログアウト
の三つです。
またログイン
に関しては、ログアウト状態の時のみ表示するようにしています。
ちなみに<></>
は React の Fragment というもので、これを使うことで不要な<div>
タグを生成せずに複数の要素を返すことができます。
同様に登録画面とマイページも認証されていないユーザーには、ログインが必要である旨を条件分岐で表示させます。
処理自体は特に新しいことをしていないので解説は省きます。
+ import type { User } from "@firebase/auth";
import { Metric } from "@tremor/react";
import React, { ChangeEventHandler, FC, useCallback, useState } from "react";
+ type RecordStudyProps = {
+ user: User | null;
+ };
+ export const RecordStudy: FC<RecordStudyProps> = ({ user }) => {
const [title, setTitle] = useState("");
const [detail, setDetail] = useState("");
const [hour, setHour] = useState("");
const [minute, setMinute] = useState("");
const [time, setTime] = useState(0);
const handleHour: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
if (parseInt(e.target.value) > parseInt(e.target.max) || e.target.value.length > 2) {
setHour("");
setTime(parseInt("0") * 60 + parseInt(minute));
return;
}
setHour(e.target.value.trim());
setTime(
parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim()) * 60 +
parseInt(minute === "" ? "0" : minute)
);
},
[minute]
);
const handleMinute: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
if (parseInt(e.target.value) > parseInt(e.target.max) || e.target.value.length > 2) {
setMinute("");
setTime(parseInt(hour) * 60 + parseInt("0"));
return;
}
setMinute(e.target.value.trim());
setTime(
parseInt(hour === "" ? "0" : hour) * 60 +
parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim())
);
},
[hour]
);
+ if (!user) {
+ return <div>学習を記録するにはログインが必要です。</div>;
+ }
return (
<div className="flex w-full justify-center">
<form className="w-full rounded-3xl bg-white p-5 drop-shadow-md md:w-[50rem]">
<div className="mb-6">
<Metric className=" text-center text-gray-600">学習を記録</Metric>
<label htmlFor="title" className=" mb-2 block text-sm font-medium text-gray-900">
学習内容
</label>
<input
id="title"
type="text"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="例)数学A 問題集"
required
onChange={(e) => setTitle(e.target.value)}
value={title}
/>
</div>
<div className="mb-6">
<label htmlFor="hour" className="mb-2 block text-sm font-medium text-gray-900">
学習時間
<span className="px-1 text-gray-500">(時間:分)</span>
</label>
<div className="block h-10 w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 text-sm text-gray-900 shadow-sm">
<div className="flex h-full w-full items-center">
<input
type="number"
inputMode="numeric"
id="hour"
min="0"
max="24"
placeholder="00"
className="w-8 bg-gray-50 text-center outline-none focus:bg-sky-100"
onChange={handleHour}
value={hour}
/>
<span className="px-1">:</span>
<input
type="number"
inputMode="numeric"
id="minute"
min="0"
max="99"
placeholder="00"
className="w-8 bg-gray-50 text-center outline-none focus:bg-sky-100"
onChange={handleMinute}
value={minute}
/>
</div>
</div>
</div>
<div className="mb-6 ">
<label htmlFor="detail" className="mb-2 block text-sm font-medium text-gray-900">
メモ
</label>
<textarea
id="detail"
className="block h-40 w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500"
onChange={(e) => setDetail(e.target.value)}
value={detail}
></textarea>
</div>
<div className="flex w-full justify-center py-5">
<button
type="submit"
className="w-full rounded-lg border border-sky-800 px-10 py-2.5 text-center text-sm font-medium text-sky-800 duration-300 hover:border-white hover:bg-sky-500 hover:text-white active:bg-sky-800"
>
記録する
</button>
</div>
</form>
</div>
);
};
+ import type { User } from "@firebase/auth";
import { AreaChart, Metric } from "@tremor/react";
+ import React, { FC, useState } from "react";
+ type MyPageProps = {
+ user: User | null;
+ };
type Log = {
date: string;
"": number;
};
const timeFormatter = (time: number) => {
return `${Math.floor(time / 60)}時間${time % 60}分`;
};
const dateFormatter = (date: Date) => {
return `${date.getMonth() + 1}/${date.getDate()}`;
};
const dummyData: Log[] = [];
for (let i = 0; i < 30; i++) {
dummyData.push({
date: dateFormatter(new Date(2022, 8, i)),
"": Math.floor(Math.random() * 100),
});
}
+ export const MyPage: FC<MyPageProps> = ({ user }) => {
const [chartData, setChartData] = useState<Log[] | null>(dummyData);
+ if (!user) {
+ return <div>マイページを確認するためにはログインが必要です。</div>;
+ }
return (
<div className="hidden-scrollbar flex w-screen justify-center overflow-hidden">
<div className="hidden-scrollbar container flex flex-col items-center overflow-scroll rounded-3xl bg-white pt-4 drop-shadow-md md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
<div className=" container flex h-80 flex-col items-center pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
<Metric className=" text-gray-600">学習チャート</Metric>
{chartData && (
<AreaChart
className="h-72 px-5 py-10"
data={chartData}
index={"date"}
categories={[""]}
colors={["indigo"]}
valueFormatter={timeFormatter}
showLegend={false}
showTooltip={true}
yAxisWidth={80}
></AreaChart>
)}
</div>
</div>
</div>
);
};
上記の処理に追記により、例えばログインしていない状態で/my-page
に手打ちでアクセスすると以下のような表示になります。
8 学習記録機能の実装
8.1 Firestore にコレクションを追加
ここからはいよいよ Firestore に学習記録を保存する機能を実装していきます。
Firestore は NoSQL のデータベースです。NoSQL のデータベースは RDB とは異なり、テーブルのような概念がなく、コレクションとドキュメントという概念でデータを管理します。
少しクセがあるのですが、Windows のエクスプローラーのようにフォルダのような構造でデータを管理していると思えばわかりやすいかもしれないです。
以下の公式ドキュメントにイラストがあるので参考にして見てください。
Firebase コンソールから、プロジェクトの Firestore を開き、コレクションを追加
ボタンをクリックします。
学習記録を保存するstudylog
という ID で入力し、次へ
をクリック
ドキュメント ID は自動生成にしておきます。
自動ID
ボタンをクリック
studylog
コレクションが作成されていることを確認します。
空のドキュメントは不要なので、先ほど作成したドキュメント ID 文字列の横にある 3 点リーダーをクリックし、ドキュメント
を削除をクリック
誤ってコレクションを削除してしまった場合は再度作成していただければ問題ありません。
8.2 セキュリティルールの更新
続いて、セキュリティルールを更新します。
ルール
タブをクリックしすると、以下のようなルールが記述されていることが確認できます。
これは全てのドキュメントに対して、誰でも読み取りはできるが書き込みはできないという設定になっています。
上記のままでは書き込みができませんので以下のように修正します。
修正したら公開
ボタンをクリックして保存します。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
- match /{document=**} {
- allow read, write: if true;
+ match /studylog/{docs} {
+ allow read;
+ allow create: if request.auth != null &&
+ request.resource.data.author.id is string &&
+ request.resource.data.author.id == request.auth.uid &&
+ request.resource.data.author.username is string &&
+ request.resource.data.author.photoUrl is string &&
+ request.resource.data.createdAt is timestamp &&
+ request.resource.data.detail is string &&
+ request.resource.data.hour is string &&
+ request.resource.data.minute is string &&
+ request.resource.data.time is number;
+ allow delete: if resource.data.author.id == request.auth.uid;
}
}
}
match /studylog/{docs} {
は先ほど作成した studylog コレクションに対してのルールを記述しています。
allow read;
は誰でも読み取りができることを意味しています。
allow create: if 条件
はドキュメントの作成する際のルールを記述しています。
request.resource.data.xxx
はリクエスト時に送られてきたデータの xxx というプロパティを取得しています。
つまりドキュメントを作成することができる条件は
- ユーザーがログインしていること
- ドキュメントの
auther.id
が string 型であること - ドキュメントの
auther.id
がログインしているユーザーの ID と一致すること - username が string 型であること
- photoUrl が string 型であること
- createdAt が timestamp 型 であること
- detail が string 型であること
- hour が string 型であること
- minute が string 型であること
- time が number 型であること
を全て満たしていることです。
このように、ドキュメントの作成時に送られてきたデータを検証することで、不正なデータを保存することを防ぐことができます。
フロント側である程度バリデーションを効かせるできますが、ユーザーからリクエストされたデータは信用できないことを前提にルールを設定することが大切です。
allow delete: if 条件
はドキュメントの削除する際のルールを記述しています。
上記ではドキュメントのauther.id
がログインしているユーザーの ID と一致することを条件にしています。
つまり、自分が作成したドキュメントのみ削除することができるということです。
繰り返しになりますが、Firebase の環境構築時に記述した構成情報は公開前提のキーのため、セキュリティルールを適切に設定することが大切です。
アプリを構築する際は公式ドキュメントを読み込んでから実装することをおすすめします。
8.3 投稿機能の実装
それでは VSCode に戻り、投稿機能を実装していきましょう。
RecordStudy に追記していきます。
import type { User } from "@firebase/auth";
import { Metric } from "@tremor/react";
+ import { addDoc, collection, serverTimestamp } from "firebase/firestore";
import React, { ChangeEventHandler, FC, FormEvent, useCallback, useState } from "react";
+ import { useNavigate } from "react-router-dom";
+ import { auth, db } from "../firebase";
type RecordStudyProps = {
user: User | null;
};
export const RecordStudy: FC<RecordStudyProps> = ({ user }) => {
const [title, setTitle] = useState("");
const [detail, setDetail] = useState("");
const [hour, setHour] = useState("");
const [minute, setMinute] = useState("");
const [time, setTime] = useState(0);
+ const navigate = useNavigate();
const handleHour: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
if (parseInt(e.target.value) > parseInt(e.target.max) || e.target.value.length > 2) {
setHour("");
setTime(parseInt("0") * 60 + parseInt(minute));
return;
}
setHour(e.target.value.trim());
setTime(
parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim()) * 60 +
parseInt(minute === "" ? "0" : minute)
);
},
[minute]
);
const handleMinute: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
if (parseInt(e.target.value) > parseInt(e.target.max) || e.target.value.length > 2) {
setMinute("");
setTime(parseInt(hour) * 60 + parseInt("0"));
return;
}
setMinute(e.target.value.trim());
setTime(
parseInt(hour === "" ? "0" : hour) * 60 +
parseInt(e.target.value.trim() === "" ? "0" : e.target.value.trim())
);
},
[hour]
);
+ const recordStudy = async (e: FormEvent<HTMLFormElement>) => {
+ e.preventDefault();
+
+ await addDoc(collection(db, "studylog"), {
+ title: title,
+ author: {
+ id: auth.currentUser?.uid,
+ photoUrl: auth.currentUser?.photoURL,
+ username: auth.currentUser?.displayName,
+ },
+ createdAt: serverTimestamp(),
+ detail: detail,
+ hour: `${Math.floor(time / 60)}`,
+ minute: `${time % 60}`,
+ time: time,
+ updatedAt: serverTimestamp(),
+ });
+ navigate("/");
+ };
if (!user) {
return <div>学習を記録するにはログインが必要です。</div>;
}
return (
<div className="flex w-full justify-center">
<form
+ onSubmit={recordStudy}
className="w-full rounded-3xl bg-white p-5 drop-shadow-md md:w-[50rem]"
>
<div className="mb-6">
<Metric className=" text-center text-gray-600">学習を記録</Metric>
<label htmlFor="title" className=" mb-2 block text-sm font-medium text-gray-900">
学習内容
</label>
<input
id="title"
type="text"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="例)数学A 問題集"
required
onChange={(e) => setTitle(e.target.value)}
value={title}
/>
</div>
<div className="mb-6">
<label htmlFor="hour" className="mb-2 block text-sm font-medium text-gray-900">
学習時間
<span className="px-1 text-gray-500">(時間:分)</span>
</label>
<div className="block h-10 w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 text-sm text-gray-900 shadow-sm">
<div className="flex h-full w-full items-center">
<input
type="number"
inputMode="numeric"
id="hour"
min="0"
max="24"
placeholder="00"
className="w-8 bg-gray-50 text-center outline-none focus:bg-sky-100"
onChange={handleHour}
value={hour}
/>
<span className="px-1">:</span>
<input
type="number"
inputMode="numeric"
id="minute"
min="0"
max="99"
placeholder="00"
className="w-8 bg-gray-50 text-center outline-none focus:bg-sky-100"
onChange={handleMinute}
value={minute}
/>
</div>
</div>
</div>
<div className="mb-6 ">
<label htmlFor="detail" className="mb-2 block text-sm font-medium text-gray-900">
メモ
</label>
<textarea
id="detail"
className="block h-40 w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500"
onChange={(e) => setDetail(e.target.value)}
value={detail}
></textarea>
</div>
<div className="flex w-full justify-center py-5">
<button
type="submit"
className="w-full rounded-lg border border-sky-800 px-10 py-2.5 text-center text-sm font-medium text-sky-800 duration-300 hover:border-white hover:bg-sky-500 hover:text-white active:bg-sky-800"
>
記録する
</button>
</div>
</form>
</div>
);
};
記録する
ボタンを押すかエンターを押した時(submit アクションが実行された時)に発火させる recordStudy 関数を定義しています。
フォームからはイベントを受け取り、e.preventDefault()
を発火させることでページがリロードされるのを防いでいます。
Firebase にドキュメントを追加するにはaddDoc
関数を使用します。
自動 ID でドキュメントを追加するには addDoc の第 1 引数にcollection
関数を渡します。
collection の第 1 引数には getFirestore で取得した DB 情報。第 2 引数にはコレクション名を渡します。
addDoc
の第 2 引数にはドキュメントのデータをオブジェクト形式で渡します。
ここではフォームから受け取ったデータをオブジェクトにして渡しています。
author
にはログインしているユーザーの情報を渡しています。そのためフォームに直接入力する欄は設けていません。
createdAt
とupdatedAt
にはserverTimestamp
という Firebase ですでに用意されている関数を使用して送信日時を渡しています。
await で上記の送信処理が完了したのちに useNavigate でホーム画面にリダイレクトさせています。
8.4 動作確認
動作確認していきましょう。
ログインを済ませて、記録画面からフォームに入力して送信してみます。
送信が完了するとホーム画面へ遷移します。
ホーム画面にデータの取得処理を実装していないため見た目の変化はありませんが後で実装するのでご安心ください。
Firebase のコンソールを確認するとドキュメントが追加されていれば成功です。
9 投稿データを一覧に表示
ホーム画面からダミーデータを削除して、Firebase から取得したものに差し替えていきます。
+ import { collection, getDocs, limit, orderBy, query } from "firebase/firestore";
import React, { useEffect, useState } from "react";
import { Card } from "../components/Card";
+ import { db } from "../firebase";
import { StudyLog } from "../types/types";
- const dummyData: StudyLog[] = [];
- for (let i = 0; i < 100; i++) {
- dummyData.push({
- id: i.toString(),
- title: `第${i}回React勉強会`,
- author: {
- id: i.toString(),
- photoUrl: "https://via.placeholder.com/150/92c952",
- username: "takumi",
- },
- createdAt: new Date().toString(),
- detail: "Reactの勉強をしました",
- hour: "1",
- minute: "30",
-
- time: 90,
- });
- }
export const Home = () => {
- const [studyLog, setStudyLog] = useState<StudyLog[]>(dummyData);
+ const [studyLog, setStudyLog] = useState<StudyLog[]>([]);
+ useEffect(() => {
+ const getStudyLogs = async () => {
+ const studylogRef = collection(db, "studylog");
+ const data = await getDocs(query(studylogRef, orderBy("createdAt", "desc"), limit(100)));
+ setStudyLog(
+ data.docs.map((doc) => ({
+ id: doc.id,
+ title: doc.data().title,
+ author: {
+ id: doc.data().author.id,
+ photoUrl: doc.data().author.photoUrl,
+ username: doc.data().author.username,
+ },
+ createdAt: doc.data().timestamp,
+ detail: doc.data().detail,
+ hour: doc.data().hour,
+ minute: doc.data().minute,
+ time: doc.data().time,
+ }))
+ );
+ };
+ getStudyLogs();
+ // 依存関係にstudyLogを加えると無限ループになってしまうので注意!
+ }, []);
return (
<div className=" hidden-scrollbar flex w-screen justify-center overflow-hidden">
<div className="hidden-scrollbar container flex h-full flex-col items-center overflow-scroll pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
<div className="container flex flex-col items-center pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
{studyLog.map((card) => (
<Card key={card.id} cardInfo={card} />
))}
</div>
</div>
</div>
);
};
studyLog の useState の初期値は空配列にしておきます。(データが 1 件も取得されなければ何も表示されない状態にしておく)
useEffect の中で、Firebase からデータを取得する処理を実装します。
Firebase からデータを取得する処理は、getDocs
関数を使います。
getDocs 関数は、引数にクエリを渡すことで、条件に合致するドキュメントを取得することができます。
ホーム画面のデータ取得クエリは、studylog コレクションから createdAt フィールドを降順で 100 件取得するというものです。
取得したデータをdata
という変数に格納して、setStudyLog
関数を使って state を更新します。
取得したデータは、data.docs
に格納されているので、map 関数を使ってそれぞれ必要なプロパティを詰め替えています。
最後に定義した getStudyLogs を実行してあげると、Firebase からデータを取得して表示することができます。
うまくいくと先ほど記録したデータが表示されるはずです。
10 チャートの実装
続いてマイページのチャートデータを差し替えていきましょう。
こちらも同様にダミーデータを削除して Firebase から値を取得します。
import type { User } from "@firebase/auth";
import { AreaChart, Metric } from "@tremor/react";
+ import { collection, getDocs, limit, orderBy, query, where } from "firebase/firestore";
+ import React, { FC, useEffect, useState } from "react";
+ import { db } from "../firebase";
type MyPageProps = {
user: User | null;
};
type Log = {
date: string;
"": number;
};
+ type Result = {
+ date: Date;
+ time: number;
+ };
const timeFormatter = (time: number) => {
return `${Math.floor(time / 60)}時間${time % 60}分`;
};
- const dummyData: Log[] = [];
- for (let i = 0; i < 30; i++) {
- dummyData.push({
- date: dateFormatter(new Date(2022, 8, i)),
- "": Math.floor(Math.random() * 100),
- });
- }
export const MyPage: FC<MyPageProps> = ({ user }) => {
+ const [chartData, setChartData] = useState<Log[] | null>();
+ const studylogRef = collection(db, "studylog");
+
+ const getLatestDate = async (): Promise<Date> => {
+ const latestData = await getDocs(
+ query(
+ studylogRef,
+ where("author.id", "==", user?.uid),
+ orderBy("createdAt", "desc"),
+ limit(1)
+ )
+ );
+ const latestDate: Date = await latestData.docs[0].data().createdAt.toDate();
+ return latestDate;
+ };
+
+ const findThirtyDaysAgo = (date: Date): Date => {
+ const thirtyDaysAgo: Date = new Date(date.getTime() - 30 * 24 * 60 * 60 * 1000);
+ thirtyDaysAgo.setHours(0, 0, 0, 0);
+ return thirtyDaysAgo;
+ };
const dateFormatter = (date: Date) => {
return `${date.getMonth() + 1}/${date.getDate()}`;
};
+ useEffect(() => {
+ const getMyLogs = async () => {
+ // // 一番最新のデータの日付
+ const latestDate: Date = await getLatestDate();
+ const thirtyDaysAgo: Date = findThirtyDaysAgo(latestDate);
+ const data = await getDocs(
+ query(
+ studylogRef,
+ where("author.id", "==", user?.uid),
+ where("createdAt", ">=", new Date(thirtyDaysAgo)),
+ orderBy("createdAt", "desc")
+ )
+ );
+ const result = data.docs
+ // 日付と時間を取得
+ .map((doc) => ({
+ // 0時0分0秒にする
+ date: new Date(doc.data().createdAt.toDate().setHours(0, 0, 0, 0)),
+ time: doc.data().time,
+ }))
+ .reduce((acc: Result[], cur: Result) => {
+ const target = acc.find((item) => item.date.getTime() === cur.date.getTime());
+ if (target) {
+ target.time += cur.time;
+ } else {
+ acc.push(cur);
+ }
+ return acc;
+ }, []);
+ const addMissingData = (result: Result[]) => {
+ // 対象日のデータがない場合は、timeが0のデータを追加
+ for (let i = 0; i < 30; i++) {
+ const targetDate = new Date(thirtyDaysAgo.getTime() + i * 24 * 60 * 60 * 1000);
+ const target = result.find((item) => item.date === targetDate);
+ if (!target) {
+ result.push({
+ date: targetDate,
+ time: 0,
+ });
+ }
+ }
+ result.sort((a, b) => {
+ if (a.date < b.date) {
+ return -1;
+ } else {
+ return 1;
+ }
+ });
+ setChartData(
+ result.map((doc) => ({
+ date: `${dateFormatter(doc.date)}`,
+ "": doc.time,
+ }))
+ );
+ };
+ addMissingData(result);
+ };
+ getMyLogs();
+ }, [user]);
if (!user) {
return <div>マイページを確認するためにはログインが必要です。</div>;
}
return (
<div className="hidden-scrollbar flex w-screen justify-center overflow-hidden">
<div className="hidden-scrollbar container flex flex-col items-center overflow-scroll rounded-3xl bg-white pt-4 drop-shadow-md md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
<div className=" container flex h-80 flex-col items-center pt-4 md:flex-row md:flex-wrap md:items-start md:justify-around md:px-4">
<Metric className=" text-gray-600">学習チャート</Metric>
{chartData && (
<AreaChart
className="h-72 px-5 py-10"
data={chartData}
index={"date"}
categories={[""]}
colors={["indigo"]}
valueFormatter={timeFormatter}
showLegend={false}
showTooltip={true}
yAxisWidth={80}
></AreaChart>
)}
</div>
</div>
</div>
);
};
まず、一時的に値を詰め替えるための Result という型を定義しています。
続いて、chartData という状態を useState で作成します。
チャートには、一番直近のデータから 30 日前までのデータを表示するようにします。
そのためまず直近のデータがいつのものなのか取得する必要があります。
そこで、getLatestDate という関数を作成し、最新のデータを取得するようにしています。
次に直近データから 30 日前がいつなのかを割り出す findThurtyDaysAgo 関数を定義します。
データの取得は useEffect 内で行なっています。
チャートに使用するデータをまず data という変数に格納しています。
result 関数ではクエリ対象となるデータから日付と時間だけを取り出しています。
日毎のデータとしてチャート作成したいため、取得したデータの記録時間を 0 時 0 分 0 秒にして、JavaScript の redule 関数で同じ日付のデータをまとめています。
addMissingData 関数では学習記録がない日付を学習時間 0 とするようにデータを追加しています。
その後、日付順にソートし、チャート用のステートに格納しています。
最後にそれぞれの関数を実行し、チャートのデータ処理が完了です。
しかこのままではマイページを表示しても何も表示されません。
Where 句で絞り込む時に複合インデックスを貼る必要があるため、コンソールに出力されている URL に飛んで設定を行いましょう。
Firebase のコンソールに飛ぶと、自動的にインデックスを作成してくれるためそのまま保存します。
ビルド中から有効になれば、マイページにチャートが表示されるようになります。
ビルドには少し時間がかかるため気長に待ってください。
11 Hosting
これで 1 通りアプリの実装が完了しました。お疲れ様でした。🍵
最後にデプロイまでしてしまいましょう。
まずはプロジェクトから Hosting 画面に遷移します。
始める
をクリックします。
FirebasHosting の設定を行います。
上記の画面に指示されたコマンドをコピペして Firebase CLI をインストールします。インストールが完了したら、上記画面の次へ
をクリックします。
npm install -g firebase-tools
firebase login
firebase init
firebas init を実行すると、以下のような質問がされます。
矢印キーで移動し、スペースキーで選択し、Enter キーで決定します。
❯◉ Hosting: Configure files for Firebase Hosting and (optionally) set up GitHub Action
を選択します。
? Which Firebase features do you want to set up for this directory? Press Space to select
features, then Enter to confirm your choices. (Press <space> to select, <a> to toggle all,
<i> to invert selection, and <enter> to proceed)
provision default instance
◯ Firestore: Configure security rules and indexes files for Firestore
◯ Functions: Configure a Cloud Functions directory and its files
❯◉ Hosting: Configure files for Firebase Hosting and (optionally) set up GitHub Action
deploys
◯ Hosting: Set up GitHub Action deploys
◯ Storage: Configure a security rules file for Cloud Storage
(Move up and down to reveal more choices)
Use an exsizting project
を選択します。
First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add,
but for now we'll just set up a default project.
? Please select an option:
❯ Use an existing project
Create a new project
Add Firebase to an existing Google Cloud Platform project
Don't set up a default project
作成したアプリ名を選択します。
? Select a default Firebase project for this directory: (Use arrow keys)
❯ just-do-it-1c79b (just-do-it)
build
と指定します。
Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.
? What do you want to use as your public directory? build
y
を指定します。
? Configure as a single-page app (rewrite all urls to /index.html)? (y/N) y
N
を指定します。
? Set up automatic builds and deploys with GitHub? (y/N) N
N
を指定します。
? File build/index.html already exists. Overwrite? (y/N) N
✔ Firebase initialization complete!
と表示されれば完了です。
続いてデプロイ前にビルドしておきます。
npm run build
最後にデプロイします。
firebase deploy
Hosting URL が表示されれば成功です。
その URL が本番環境になります。
11.APIに制限をかける
おまけに GCP から API を利用するドメインの制限をかけましょう。
GCP と検索すれば` Google Cloud - Google Cloud Platform(GCP)`みたいなページが出てくるのでコンソールにアクセスしてください。
ヘッダーのドロップダウンを開いて対象のアプリケーションを選択します。
ハンバーガーメニューを開いてAPIとサービス > 認証情報
へ進みます。
アプリケーションの制限の設定
にウェブサイト
を選択し、ADD
ボタンをクリックします。
deployコマンドの実行時に出力されたドメインを追加します。
追加したら保存してください。
これで対象のドメイン以外からAPIを使用できなくなりました。
試しにしばらく経ってから、ローカル環境を立ち上げてみてください。正常にデータを取得できなければ設定が完了しています。
ローカル環境で同じAPIを使用したい場合は、http://localhost:3000
みたいな感じで追加すると使用できますが、それは第3者でも同じことなのでそれを踏まえて検討してください。
他にもAPIを保護するためにできることはいくつかありますが、今回はこの辺りで終わりたいと思います。
以下にAPIを保護するためのベストプラクティスを貼っておきますので、興味がある方はやってみてください。
12. 終わりに
今回作成したアプリはまだまだ未完成なので気が向いたらリファクタや追加実装してみてください。
- userのステート管理をグローバルにする
- サインインウィザードを作成する
- マイページに任意の期間でデータを取得できる機能をつける
- ユーザー情報や投稿の編集機能をつける
ect.