#今回作ったもの
ログイン機能付きの収支管理アプリです。
いわゆる家計簿アプリ的な物です。
毎月の収入と支出をリスト化し月の残高を表示します。
感想編については、別途記事を書いてるので、ご興味ございましたら是非ご覧下さいませ!
初心者がReact学習歴1週間でWebアプリ作成に挑戦してみた
#コードについて
長くなってしまうので全部は載せておりません。
必要だと思うところだけ解説してます。
スタイルを基本CSSで装飾してますが、className
は邪魔だと思うのでこの記事では消してます。
全コードはGithubに載せてます。
→こちら
#使用技術
- React(version 16.13.1)
- Router
- クラスの代わりにHooks (useState, useEffect, useContext)
- Firebase
- Authentication
- Cloud Firestore
- Hosting
#Reactの準備
create react appで作成
npx create-react-app my-app
cd my-app
npm start
作成方法は手順に従えば大丈夫です。
簡単なので割愛しますが、全体の流れはこちらのYoutubeを参考にしました。
日本一わかりやすいReact入門【実践編】#3...Firebaseプロジェクトの作成と初めてのデプロイ
注意点として、node.jsとfirebaseのバージョンにより少し挙動が動画内容と異なります。
ポイントは、
- ロケーション設定は初めにやる。東京なら「asia-northeast1」
- プランは現在の安定node versionだとSparkではなくBlazeプラン(従量制)になります
- Blazeプランなので、念の為Google Cloud Platformを作成して、アクセス& 料金状況/アラーム通知を受信できるよう設定します(任意)
###Authenticationの設定
Sign-in methodよりステータスを有効にする
今回は「メール/パスワード」を使用
###Cloud Firestoreでデータベース作成
- テストモードで実行
- とりあえずコレクション/ドキュメントを追加してみる(イメージのため)
最終的なコレクション/ドキュメント構成はこちらです。
#Firebaseの設定
Firebaseの情報をwebアプリに登録します。
プロジェクト内容は一応セキュリティを考慮し.env
に登録します。
.env
をgitignore
すれば公開される心配がないということですね。
REACT_APP_FIREBASE_KEY="APIキー"
REACT_APP_FIREBASE_DOMAIN="プロジェクト.firebaseapp.com"
REACT_APP_FIREBASE_DATABASE="https://プロジェクト.firebaseio.com"
REACT_APP_FIREBASE_PROJECT_ID="プロジェクト"
REACT_APP_FIREBASE_STORAGE_BUCKET="プロジェクト.appspot.com"
REACT_APP_FIREBASE_SENDER_ID="ID番号"
src
ディレクトリ直下にfirebase
ディレクトリとFirebase.js
ファイルを作成します。
ここでFirebaseの初期化処理が行われます。
auth
とdb
も作り、毎回全部書かなくて済むようにします。
import firebase from "firebase/app";
import "firebase/auth";
import "firebase/firestore";
firebase.initializeApp({
apiKey: process.env.REACT_APP_FIREBASE_KEY,
authDomain: process.env.REACT_APP_FIREBASE_DOMAIN,
databaseURL: process.env.REACT_APP_FIREBASE_DATABASE,
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.REACT_APP_FIREBASE_SENDER_ID
});
const auth = firebase.auth();
const db = firebase.firestore();
export { auth, db }
#ログイン機能の実装
auth
ディレクトリを作成し、こちらに認証機能系は集約させます。
ディレクトリ構成
src
├── auth
├── AuthProvider.js
└── Login.js
└── PrivateRoute.js
└── SignUp.js
├── components
├── firebase
├── App.js
まず、App.js
にログイン状態で表示ページを変える為、Router
を作ります。
exact
はpathが「含む」とならないように指定。
const App = () => {
return (
<AuthProvider>
<Router>
<Switch>
<PrivateRoute exact path="/" component={Home} />
<Route exact path="/login" component={Login} />
<Route exact path="/signup" component={SignUp} />
</Switch>
</Router>
</AuthProvider>
);
};
export default App;
###AuthPrivider.js
認証の情報(ユーザーがログイン、サインアップする)は、こちらで作ります。
そしてユーザー情報が必要なコンポーネントでuseContext
を使います。
通常データはトップダウン形式でprops
を渡さないといけないですが、
context
を使うことで、コンポーネントツリーに簡単にデータを共有することができます。
useContext
についてこちらの記事が非常にわかりやすかったです。
useContextの使い方
こんなに簡単なの?React useContextって
import React, { useEffect, useState } from "react";
import { auth } from "../firebase/Firebase";
const AuthContext = React.createContext()
const AuthProvider = ({ children }) => {
const [currentUser, setCurrentUser] = useState(null);
//サインアップ後認証情報を更新
const signup = async (email, password, history) => {
try {
await auth.createUserWithEmailAndPassword(email, password);
auth.onAuthStateChanged(user => setCurrentUser(user));
history.push("/");
} catch (error) {
alert(error);
}
};
//ログインさせる
const login = async (email, password, history) => {
try {
await auth.signInWithEmailAndPassword(email,password);
auth.onAuthStateChanged(user => setCurrentUser(user));
history.push("/");
} catch (error) {
alert(error);
}
}
//初回アクセス時に認証済みかチェック
useEffect(() => {
auth.onAuthStateChanged(setCurrentUser);
}, []);
return (
<AuthContext.Provider value={{ signup, login, currentUser}}>
{children}
</AuthContext.Provider>
)
}
export {AuthContext, AuthProvider}
初期値のユーザーのステートはnull
ですね。
signup
関数
引数にemail
、password
、history
を渡して非同期処理を行います。
auth.createUserWithEmailAndPassword(email, password)
は、
firebaseのメソッドでemail
とpassword
を元にアカウントが作成されます。
その後user
の情報を取得し、CurrentUser
にセットします。
history.push("/")
は、reactRouterの画面遷移させる機能です。
今回はログイン後、Home画面に行きます。
login
関数
同じ様に、ユーザーがログインしたら情報を取得しCurrentUser
を更新するようにします。
auth.signInWithEmailAndPassword(email,password)
これもまたfirebaseのメソッドです。
あとは、最初にログインしてるか確認する為、useEffect
で一回だけauth.onAuthStateChanged
を実行します。
※一回だけ実行したいので、第二引数には空の配列[]
を渡します。
###PrivateRoute.js
アプリのメイン画面Home.js
は、PrivateRoute
に指定します。
ここで、ユーザーがログインしてれば→メイン画面を表示。
未ログインの場合→ログイン画面を表示。の作業を行なってます。
import React, { useContext } from "react";
import { Route } from "react-router-dom";
import { AuthContext } from "./AuthProvider";
import Login from "./Login";
const PrivateRoute = ({ component, ...rest}) => {
const { currentUser } = useContext(AuthContext);
//AuthContextからcurrentUserを受け取る
const Component = currentUser ? component : Login;
//currentUserがtrueの場合component=Home、falseならLoginコンポーネントにroute
return <Route {...rest} component={Component} />;
};
export default PrivateRoute;
...rest
は、残りのpropsをまとめて引数に渡してます(今回他のpropsはないですが)
この...
ですが、以前RestParametersの記事を書きましたのでよろしければご参照ください。
ES6の新しい構文です!
スプレッド構文とRestパラメータを理解する
###SignUp.jsとLogin.js
SignUp.jsコンポーネントでは、ユーザー登録画面の表示、登録内容を取得します。
handleSubmit
が実行される時、入力されたemail
とpassword
の内容をAuthProvider
で作ったsignup
関数の引数に渡してデータが登録されます。
アップデート後のhistory(情報)を渡すために、最後exportの時withRouter(SignUp)
を使っています。
const SignUp = ({ history }) => {
const { signup } = useContext(AuthContext);
//AuthContextからsignup関数を受け取る
const handleSubmit = event => {
event.preventDefault();
const { email, password } = event.target.elements;
signup(email.value, password.value, history);
};
return (
<div>
<h1>Sign Up</h1>
<form onSubmit={handleSubmit}>
<div>
<label>E-mail Address</label>
<input name="email" type="email" placeholder="email@gmail.com" />
</div>
<div>
<label>Password</label>
<input name="password" type="password" placeholder="Password"/>
</div>
<SignUpButton type="submit">SIGN UP</SignUpButton>
</form>
<Link to="/login">SignInへ戻る</Link>
</div>
</div>
);
};
export default withRouter(SignUp);
Login.jsも似たような感じで作ります。
signup
部分をlogin
に変えるだけですね。
const Login = ({ history }) => {
const { login } = useContext(AuthContext);
//AuthContextからlogin関数を受け取る
const handleSubmit = event => {
event.preventDefault();
const { email, password } = event.target.elements;
login(email.value, password.value, history);
};
#コンポーネント・ファイル構成
続いてメイン画面です。
アプリのメイン画面を担うコンポーネント達がこちら。
メイン(親)のコンポーネントは、Home.jsになります。
src
├── auth
├── components
├── Home.js
└── Header.js
└── Balance.js
└── IncomeExpense.js
└── AddItem.js
└── ItemsLists.js
└── IncomeItem.js
└── ExpenseItems.js
└── TotalIncomeExpense.js //共通関数ファイル
├── firebase
├── App.js
#ステートの作成/更新
親コンポーネントから子コンポーネントに渡す為、ステートは全てHome.js
で作成します。
作成したステート達
const [inputText, setInputText] = useState("");
const [inputAmount, setInputAmount] = useState(0);
const [incomeItems, setIncomeItems] = useState([]);
const [expenseItems, setExpenseItems] = useState([]);
const [type, setType] = useState("inc");
const [date, setDate] = useState(new Date());
収入incomeの配列incomeItems
と、支出expenseの配列expenseItems
は、後々計算が楽なので分けて作成。
###データを追加する
Firestoreからデータを取得・追加は全てHome.js
で行います。
こちらは追加バージョン
const addIncome = (text, amount) => {
const docId = Math.random().toString(32).substring(2);
const date = firebase.firestore.Timestamp.now();
db.collection('incomeItems').doc(docId).set({
uid: currentUser.uid,
text,
amount,
date,
});
.then(response => {
setIncomeItems([
...incomeItems, {text: inputText, amount: inputAmount, docId: docId , date: date}
]);
});
}
- 収入income用の関数
addIncome
を用意。引数にはユーザーが入力したtext
とamount
を渡す。 -
docId
をこちらで作る。 - 収入リストが順番に並べられるように
date
を作成。firebase.firestore.Timestamp.now()
(入力時間が登録される)→firebaseのメソッド - どこのcollectionのdocumentに追加するかは、firebaseのメソッドを使用
db.collection('incomeItems').doc(docId).set({})
- setしたいデータを配列として追加
uid ~ date
- その後
.then
で、reactアプリ側のsetIncomeItems
のステートを更新
ポイントは、
ユーザーが削除ボタンを押した時、データを削除するのにdocId
を使います。
reactアプリと連動させたいので、こちら側で手動で作成してます。
※その時、数値だとエラーになるので文字列に変換.toString(32).substring(2)
手動で作らない場合は、「.set」ではなく「.add」で自動生成可能。
これと同じ内容で出費expense用の関数も用意すれば、値はFirestoreとReact上で無事追加/更新されます。
###データを取得する
Firestoreからデータを取ってきて、アプリ上で表示させます。
const getIncomeData = () => {
const incomeData = db.collection('incomeItems')
incomeData.where('uid', '==', currentUser.uid).orderBy('date').startAt(startOfMonth(date)).endAt(endOfMonth(date)).onSnapshot(query => {
const incomeItems = []
query.forEach(doc => incomeItems.push({...doc.data(), docId: doc.id}));
setIncomeItems(incomeItems);
});
}
- 取得したいデータのIncome用の関数
getIncomeData
を作成 - コレクション
incomeItems
のドキュメントを取得→変数incomeData
に代入 -
uid
が現在のユーザーと一致する場合のstartOfMonth
~endOfMonth
のドキュメントを取得 - 取得したデータを保存する空の配列
incomeItems
を作成 - その配列にドキュメントの
data
とid
をpush
(追加)する - reactアプリ側の
incomeItems
の配列を更新する
ポイントは、
orderBy
のメソッドでdate
を昇順にしてます。
リストがバラバラに表示されてしまうので、制御する為に必要です。
また、orderBy
で昇順にしようとするとfirebaseから「indexを作れ」というエラーが出ます。
その際、親切にURLが表示されるのでそこにアクセスしてindexを作ればOKです(結構時間かかります)
参考記事:複合index
startAt
とendAt
は、その月の分だけ表示させる為です。
この引数の中身については、後ほど詳細を書きます。
これと同じ内容で出費expense用の関数も用意すれば、無事Firestoreからデータを取得できて、Reactのステートに更新/表示がされます。
尚、データを取得するタイミングは、useEffect
を使って操作します。
- 最初の1回のみ実行してほしい。引数は空の配列。
-
date
が更新されるたびに実行してほしい。※date
は次で解説してますがヘッダーの月の部分です。
useEffect (() => {
getIncomeData();
getExpenseData();
}, []);
useEffect(() => {
getIncomeData();
getExpenseData();
}, [date]);
#月ごとにデータを表示させる
月ごとにデータを分けて表示させる為、ステートで作ったdate
を使います。
初期値は現在の日時が入ってます。
const [date, setDate] = useState(new Date());
###ヘッダーに渡して表示
date
は、ユーザーがヘッダーの"前月"か"次月"ボタンを押すと更新されます。
↓の関数で月の部分を変えてます。
//for Header
const setPrevMonth = () => {
const year = date.getFullYear();
const month = date.getMonth()-1;
const day = date.getDate();
setDate(new Date(year, month, day));
}
const setNextMonth = () => {
const year = date.getFullYear();
const month = date.getMonth()+1;
const day = date.getDate();
setDate(new Date(year, month, day));
}
これらはHeader.js
で使うのでprops
で渡してあげます。
<Header
date={date}
setPrevMonth={setPrevMonth}
setNextMonth={setNextMonth}
/>
Header.js
では、現在の月を表示させる為、year
とmonth
を作り、
隣に前月と次月ボタンを表示させます。
const today = date;
const year = today.getFullYear();
const month = today.getMonth()+1;
return (
<div className="head">
<SignOutButton onClick={() => auth.signOut()}>Sign Out</SignOutButton>
<div>
<button onClick={() => setPrevMonth()}>←前月 </button>
<h1>{year}年{month}月</h1>
<button onClick={() => setNextMonth()}> 次月→</button>
</div>
</div>
)
これでボタンをクリックしたら、setPrevMonth()
とsetNextMonth()
が実行され、月の表示が変わります。
#ユーザーの入力内容を操作する
ユーザーが入力した内容を取得する関数は、AddItem.js
コンポーネントで行ってます。
export const AddItem = ({ addIncome, addExpense, inputText, setInputText, inputAmount, setInputAmount, type, setType, selectedMonth, thisMonth}) => {
const typeHandler = (e) => {
setType(e.target.value);
}
const inputTextHandler = (e) => {
setInputText(e.target.value);
};
const inputAmountHandler = (e) => {
setInputAmount(parseInt(e.target.value));
}
const reset = () => {
setInputText("");
setInputAmount("");
}
const submitItemHandler = (e) => {
e.preventDefault();
if (inputText == '' || inputAmount == '0' || !(inputAmount > 0 && inputAmount <= 10000000)) {
alert ('正しい内容を入力してください')
} else if ( type === 'inc') {
addIncome(inputText, inputAmount)
reset();
} else if ( type === 'exp' ) {
addExpense(inputText, inputAmount)
reset();
}
}
const thisMonthForm = () => {
return (
<form>
<select onChange={typeHandler}>
<option value="inc">+</option>
<option value="exp">-</option>
</select>
<div>
<label>内容</label>
<input type="text" value={inputText} onChange={inputTextHandler}/>
</div>
<div>
<label>金額</label>
<input type="number" value={inputAmount} onChange={inputAmountHandler}/>
<div>円</div>
</div>
<div>
<AddButton type="submit" onClick={submitItemHandler}>追加</AddButton>
</div>
</form>
)
}
const otherMonthForm = () => {
return (
<form></form>
)
}
return (
<>
{thisMonth === selectedMonth ? thisMonthForm() : otherMonthForm()}
</>
)
}
- 収入income、出費expense、どちらに追加するのかは
type
で分けてます。 - ユーザーが、
option
を選択した時にtypeHandler
関数でtype
の値を更新します。 -
input
の値とamount
の値もonChange
で取得します。 -
amount
については、後に計算するのでparseInt
で値を数値化します。
submitItemHandler
で追加ボタンが押された時、操作してる内容はこちら。
- デフォルトのイベント(動作)をキャンセル
- 正しい内容が入力されてない場合、エラーを表示
-
inc
タイプなら、Home.js
で定義したaddIncome
の引数に
ユーザーの入力内容inputText
とinputAmount
を渡す -
exp
タイプも同様
→Firestoreのデータが追加され、reactアプリのステートも更新されるという流れ。
ヘッダーが今月なのか、今月でない月かによって表示方法を変える為、条件付きレンダーを行ってます。
(※今月のみ追加フォームを表示させる為)
この条件に使ってるselectedMonth
とthisMonth
は、
他のコンポーネント(リスト)でも使うので、Home.js
で作ってpropsで渡してます。
//operate add form and income/expense list
const selectedMonth = date.getMonth() + 1;
const today = new Date();
const thisMonth = today.getMonth() + 1;
#リストの表示
リストの表示はItemsList.js
を作り、そこでitemsに対してmap
を行い、IncomeItem
とExpenseItem
をそれぞれ表示します。
export const ItemsList = ({ deleteIncome, deleteExpense, incomeItems, expenseItems, incomeTotal, selectedMonth, thisMonth}) => {
return (
<div>
<div>
<h3>収入一覧</h3>
<ul>
{incomeItems.map((incomeItem) => (
<IncomeItem
deleteIncome={deleteIncome}
incomeText={incomeItem.text}
incomeAmount={incomeItem.amount}
incomeItem={incomeItem}
key={incomeItem.docId}
selectedMonth={selectedMonth}
thisMonth={thisMonth}
/>
))}
</ul>
</div>
<div>
<h3>支出一覧</h3>
<ul>
{expenseItems.map((expenseItem) => (
<ExpenseItem
deleteExpense={deleteExpense}
expenseText={expenseItem.text}
expenseAmount={expenseItem.amount}
expenseItem={expenseItem}
key={expenseItem.docId}
incomeTotal={incomeTotal}
selectedMonth={selectedMonth}
thisMonth={thisMonth}
/>
))}
</ul>
</div>
</div>
)
}
map
を実行して作られた一つ一つの項目を表示されるコンポーネントがこちら↓
Incomeバージョン
export const IncomeItem = ({ deleteIncome, incomeItem, incomeText, incomeAmount, thisMonth, selectedMonth}) => {
const deleteHandler = () => {
deleteIncome(incomeItem.docId);
}
const showThisMonth = () => {
return (
<li>
<div>{incomeText}</div>
<div>+{Number(incomeAmount).toLocaleString()}円</div>
<button onClick={deleteHandler}>×</button>
</li>
)
}
const showPastMonth = () => {
return (
<li>
<div>{incomeText}</div>
<div>+{Number(incomeAmount).toLocaleString()}円</div>
</li>
)
}
return (
<>
{thisMonth === selectedMonth ? showThisMonth() : showPastMonth()}
</>
)
}
Number(incomeAmount).toLocaleString()
は、カンマ「,」を表示させる為。
deleteHandler
は "×" を押した時にdleteIncome
を実行してます。
このdelteIncome
は、fireStoreのdocId
の関係上、Home.js
で定義されてます。↓
引数にこのincomeItem.docId
を渡せば該当のアイテムは削除されます。
const deleteIncome = (docId) => {
db.collection('incomeItems').doc(docId).delete();
}
firebaseのメソットを使い、incomeItems
コレクションにある、該当ドキュメントを削除してます。
あとは、スタイル上、表示の仕方を変えたいので、ここでも条件付きレンダーを行ってます。
→ showThisMonth
とshowPastMonth
で分ける。
これで今月以外、削除ボタンを表示させないようにしてます。
これと同じようにexpenseバージョンも作ればOK
#収入/支出の値を計算する
###収入と支出の各合計
値の計算はそれぞれのincomeItems
とexpenseItems
の配列から、amount
を取り出して計算します。
export const IncomeExpense = ({ incomeTotal, expenseItems }) => {
const expenseAmounts = expenseItems.map(expenseItem => expenseItem.amount);
const expenseTotal = expenseAmounts.reduce((acc, cur) => acc += cur, 0);
const percentage = () => {
if (incomeTotal >= 1) {
return `${Math.round((expenseTotal / incomeTotal) * 100)} %`;
} else {
return '---';
}
};
return (
<div>
<div>
<h2>収入</h2>
<div>
<p>+ {Number(incomeTotal).toLocaleString()}<span> 円</span></p>
</div>
</div>
<div>
<h2>支出</h2>
<div>
<p>- {Number(expenseTotal).toLocaleString()}<span> 円</span></p>
<div>{percentage()}</div>
</div>
</div>
</div>
)
}
map
とreduce
については、こちらを元に別で記事を書いています。
※filter
は途中色々変えたので、結局今回のアプリに使っていません。
支出expenseの合計計算
-
expenseAmount
にexpenseItems
の中のamount
だけ取り出した配列を代入します。 -
expenseAmount
を使って、累計を計算し、expenseTotal
とします。
これを収入incomeItems
でも同じことをします。
ただincomeバージョンについては、他のコンポーネントでも使うので、共通関数ファイルTotalIncome.js
を作成してます。
TotalIncome.js
で関数totalCalc
を作り、Home.js
でincomeItems
を引数に渡してます。
// calculate % and show total
const incomeTotal = totalCalc(incomeItems);
export const totalCalc = (incomeItems) => {
const incomeAmounts = incomeItems.map(incomeItem => incomeItem.amount);
return incomeAmounts.reduce((acc, cur) => acc += cur, 0);
};
こちらを使う他のコンポーネントとは、
割合%を表示するIncomeExpense.js
、ExpenseItem
と、計算に必要なBalance.js
になります。
###残高の計算
総合計の残高を計算をするBalance.js
コンポーネントでは、
IncomeTotal - ExpenseTotal
をすれば計算できます。
const balance = incomeTotal - expenseTotal
#アプリを公開
あとは公開するだけです!
私の場合はfirebase login
とinit
は先に済ませてました。
> firebase login
> firebase init
- 選択項目は FireStore, Functions, Hosting
- publicディレクトリはbuildにする
> npm run build
> firebase deploy
#あとがき
今回のアプリは全てデータをpropsで親から子供に渡しているので、
どうしてもメインのHome.js
が少しボリューミーになってしまいました。(そういうものなのか?)
初心者が書いたコードですので、ご理解いただければと思います。
こうした方が良い等ありましたら、是非ご指摘お願いします!
日々勉強して、もっと良い書き方でコードを書けるよう頑張ります。
#参考