Edited at

Reactハンズオン[firestoreでTodoアプリ]


React環境構築

参考:macで1からReactの環境構築をするのだ


  • brewインストール

  • nodebrewインストール

  • パスを通す(bashじゃない人がいたら言ってください)

  • nodejsのインストール

ここまで記事通り


yarnのインストール

> npm -v

6.9.0

npmが入っていれば

> npm i -g yarn


create-react-appのインストール

> yarn global add create-react-app

...
...
success Installed "create-react-app@3.1.1" with binaries:
- create-react-app
✨ Done in 11.25s.


プロジェクトを作成してみる

> create-react-app my_app

> cd my_app
> yarn start

ローカルサーバーが立ち上がってhttp://localhost:3000 にアクセスすれば

image.png

この画面が出てくるはずです

もうかっこいい


React入門


stateとprops

Reactを始めるのに欠かせないstateとpropsについて


state

コンポーネントが持つ状態(変数)

image.png


props

コンポーネント間で共有される変数

他のコンポーネントのstateをpropsとして受け取ることもできるし

他のコンポーネントにstateをpropsとして渡すこともできます

image.png


書き換えながら理解しましょう

今回はReact16.8で導入されたHooksを使用して書いていきます

これまで使用されてきた書き方との比較はhttps://ja.reactjs.org/docs/hooks-state.html を参照してください

App.jsx を以下のように変更します


App.jsx

import React, { useState } from 'react';

import './App.css';
import Todo from './pages/Todo';

function App() {
// 第1変数がstate, 第2変数がstateを変化させる関数
const [input, setInput] = useState('');
const [todoList, setTodoList] = useState([]);

const addTodo = () => {
setTodoList([...todoList, input]);
setInput('');
}

const deleteTodo = (index) => {
setTodoList(todoList.filter((_, idx) => idx !== index))
}

return (
<div className="App">
<input onChange={(e) => setInput(e.target.value)} value={input}/>
<button onClick={() => addTodo()}>追加</button>
{/* todoListという変数とdeleteTodoという関数をpropsとしてTodoコンポーネントに渡している*/}
<Todo todoList={todoList} deleteTodo={deleteTodo}/>
</div>
);
}

export default App;


> cd src

> mkdir pages
> cd pages
> mkdir Todo
> cd Todo
> touch index.jsx

src/pages/Todo配下にindex.jsxを作成してください

src/pages/Todo/index.jsxを以下のように書き換えます


index.jsx

import React from 'react';

// todoListという変数とdeleteTodoという関数をpropsとして受け取る
const Todo = ({todoList, deleteTodo}) => (
<div>
{/*受け取ったtodoListを使って表示する*/}
{todoList.map((todo, idx) => (
<div>
{todo}
<button onClick={() => deleteTodo(idx)}>削除</button>
</div>
))}
</div>
)

export default Todo;


ここまで書けばこんな感じ(↓)になるはずです

Screen Recording 2019-08-31 at 21.48.29.mov.gif

stateとprops若干わかりました??


ちょっと寄り道


styled-component

htmlのタグのスタイルをカスタムした状態でコンポーネントを定義できます

下は<p>タグを<Title>という名前で定義してスタイルを当てている例

const Title = styled.p`

font-size: 26px;
color: #0097a7;
letter-spacing: 2.8px;
font-weight: 200;
`
;

> yarn add styled-components


コンポーネントを有効活用してみましょう

先ほど作成したTodoコンポーネントを利用して完了済みTodoリストも同時に表示してみましょう

さらに完了済みボタンと戻すボタンも追加して、Todoの状態を変更できるようにしてみましょう

App.jsxを以下のように書き換える


App.jsx

import React, { useState } from 'react';

import './App.css';
import Todo from './pages/Todo';
import styled from 'styled-components'

function App() {

// 第1変数がstate, 第2変数がstateを変化させる関数
const [input, setInput] = useState('');
const [todoList, setTodoList] = useState([]);
const [finishedList, setFinishedList] = useState([]);

const addTodo = () => {
if (!!input) {
setTodoList([...todoList, input]);
setInput('');
}
}

const deleteTodo = (index) => {
setTodoList(todoList.filter((_, idx) => idx !== index))
}

const deleteFinishTodo = (index) => {
setFinishedList(finishedList.filter((_, idx) => idx !== index))
}

const finishTodo = (index) => {
deleteTodo(index)
setFinishedList([...finishedList,todoList.find((_, idx) => idx === index)])
}

const reopenTodo = (index) => {
deleteFinishTodo(index)
setTodoList([...todoList,finishedList.find((_, idx) => idx === index)])
}

return (
<div className="App">
<Title>Todoリスト</Title>
<input onChange={(e) => setInput(e.target.value)} value={input}/>
<button onClick={() => addTodo()}>追加</button>
<TodoContainer>
{/* todoListという変数とdeleteTodoという関数をpropsとしてTodoコンポーネントに渡している*/}
<SubContainer>
<SubTitle>未完了</SubTitle>
<Todo todoList={todoList} deleteTodo={deleteTodo} changeTodoStatus={finishTodo} type="todo"/>
</SubContainer>
<SubContainer>
<SubTitle>完了済み</SubTitle>
<Todo todoList={finishedList} deleteTodo={deleteFinishTodo} changeTodoStatus={reopenTodo} type="done"/>
</SubContainer>
</TodoContainer>
</div>
);
}

export default App;

const Title = styled.p`
font-size: 26px;
color: #0097a7;
letter-spacing: 2.8px;
font-weight: 200;
`
;

const SubTitle = styled.p`
font-size: 22px;
color: #5c5c5c;
`
;

const SubContainer = styled.div`
width: 400px;
`
;

const TodoContainer = styled.div`
display: flex;
flex-direction: row;
width: 80%;
margin: 0 auto;
justify-content: space-between;
`
;


src/pages/Todo/index.jsxもちょっと変更


index.jsx

import React from 'react';

import styled from 'styled-components'

// todoListという変数とdeleteTodoという関数をpropsとして受け取る
const Todo = ({todoList, deleteTodo, changeTodoStatus, type}) => (
<div>
{/*受け取ったtodoListを使って表示する*/}
{todoList.map((todo, idx) => (
<Container>
{todo}
<button onClick={() => deleteTodo(idx)}>削除</button>
<button onClick={() => changeTodoStatus(idx)}>{type === "todo" ? "完了済みにする" : "戻す"}</button>
</Container>
))}
</div>
)

export default Todo;

const Container = styled.div`
color: #5c5c5c;
letter-spacing: 1.8px;
`
;


ついでにindex.cssも変更


index.css

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;
background-color: #fff2cc; /*ここ追加*/
}

code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}


ここまで行けば

Screen Recording 2019-08-31 at 22.26.05.mov.gif

完了済みボタンを押せばTodoが完了済みリストに追加され

戻すボタンを押せばTodoが未完了リストに戻るようになるはずです


firestoreを使ってデータを永続化してみる

ここまではデータをstateで管理していたがstateはブラウザをリロードすると初期化されてしまいます

そこでデータをdbに保存して管理して上げる必要があります

今回は簡単にデータを保管できるfirebaseのcloud firestoreを使用してTodoのデータを保持してみましょう


firebaseにプロジェクトを作成してみる

image.png

プロジェクトを追加で新しいプロジェクトを作成します

image.png

適当に名前をつけて続行

image.png

アナリティクスは設定しないを押せばプロジェクトが作られます

プロジェクトの作成が完了したら

image.png

</>のマークを押します

image.png

適当にニックネームをつけてHostingは設定しなくていいです(あとでやります)

image.png

var firebaseConfig = {

...
}
firebase.initializeApp(firebaseConfig);

をコピーして次へ進んでください

一旦ターミナルに戻って先ほど作成したプロジェクトの配下で

> yarn global add firebase-tools

> yarn add firebase

コマンドラインでfirebaseにログインします

> firebase login

ログインができたらsrc/index.jsの先頭に先ほどコピーしたfirebaseのConfigを貼り付けます


index.js

import React from 'react';

import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import firebase from 'firebase';

// Your web app's Firebase configuration
const firebaseConfig = {
apiKey: "xxxxxxxxxxxxxxxxxxxxxxx",
authDomain: "xxxxxxxxxxxxxxxxxxxxxxxx",
databaseURL: "xxxxxxxxxxxxxxxxxxxxxxx",
projectId: "xxxxxxxxxxxxxx",
storageBucket: "",
messagingSenderId: "xxxxxxxxxxxx",
appId: "xxxxxxxxxxxxxxxxxxxxxx"
};
// Initialize Firebase
firebase.initializeApp(firebaseConfig);

ReactDOM.render(<App />, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();



firestoreでデータベースを作成してみる

image.png

Databaseを選択し、画面が遷移したらデータベースの作成を選択します

モーダルが表示されたらテストモードで開始を選択して次へ進みます

image.png

ロケーションはどこでもいいので完了を押します

これでfirestoreの設定は終了

画面が移動したらコレクションの開始を選択してtodoListと入力して次へ

image.png

ドキュメントIDとフィールドとタイプを以下のように設定して保存

今回は簡単に文字列の配列で保存します

image.png

次にドキュメントの追加を選択してドキュメントIDとフィールドとタイプを設定して保存

image.png

image.png

こうなればOK

image.png


reactからfirestoreを操作してみる

App.jsxを以下のように書き換えてみましょう


App.jsx

import React, { useState, useEffect } from 'react'; // 修正

import './App.css';
import Todo from './pages/Todo';
import styled from 'styled-components';
import firebase from 'firebase'; // 追記
import 'firebase/firestore'; // 追記

function App() {

// 第1変数がstate, 第2変数がstateを変化させる関数
const [input, setInput] = useState('');
const [todoList, setTodoList] = useState([]);
const [finishedList, setFinishedList] = useState([]);
// Loadingを判定する変数
const [isLoading, setIsLoading] = useState(true);
// 未完了のTodoが変化したかを監視する変数
const [isChangedTodo, setIsChangedTodo] = useState(false);
// 完了済みのTodoが変化したかを監視する変数
const [isChangedFinished, setIsChangedFinished] = useState(false);

const db = firebase.firestore(); // 追記

// 追記 一番最初にfirestoreからデータを取ってきてstateに入れる
useEffect(() => {
(async () => {
const resTodo = await db.collection("todoList").doc("todo").get();
// stateに入れる
setTodoList(resTodo.data().tasks);
const resFinishedTodo = await db.collection("todoList").doc("finishedTodo").get();
// stateに入れる
setFinishedList(resFinishedTodo.data().tasks);
// Loading終了
setIsLoading(false);
})()
}, [db])

useEffect(() => {
if (isChangedTodo) {
(async () => {
// 通信をするのでLoadingをtrue
setIsLoading(true)
const docRef = await db.collection('todoList').doc('todo');
docRef.update({ tasks: todoList })
// Loading終了
setIsLoading(false)
})()
}
}, [todoList, isChangedTodo, db])

useEffect(() => {
if (isChangedFinished) {
(async () => {
// 通信をするのでLoadingをtrue
setIsLoading(true)
const docRef = await db.collection('todoList').doc('finishedTodo');
docRef.update({ tasks: finishedList })
// Loading終了
setIsLoading(false)
})()
}
setIsChangedFinished(false)
}, [db, finishedList, isChangedFinished])

const addTodo = async () => {
if (!!input) {
// 追記 Todoが変化したのでtrue
setIsChangedTodo(true);
setTodoList([...todoList, input]);
setInput('');
}
}

const deleteTodo = (index) => {
// 追記 Todoが変化したのでtrue
setIsChangedTodo(true);
setTodoList(todoList.filter((_, idx) => idx !== index))
}

const deleteFinishTodo = (index) => {
// 追記 完了済みTodoが変化したのでtrue
setIsChangedFinished(true);
setFinishedList(finishedList.filter((_, idx) => idx !== index))
}

const finishTodo = (index) => {
// 追記 Todo、完了済みTodoがともに変化したのでtrue
setIsChangedTodo(true);
setIsChangedFinished(true);
deleteTodo(index)
setFinishedList([...finishedList,todoList.find((_, idx) => idx === index)])
}

const reopenTodo = (index) => {
// 追記 Todo、完了済みTodoがともに変化したのでtrue
setIsChangedTodo(true);
setIsChangedFinished(true);
deleteFinishTodo(index)
setTodoList([...todoList,finishedList.find((_, idx) => idx === index)])
}

return (
<div className="App">
<Title>Todoリスト</Title>
<input onChange={(e) => setInput(e.target.value)} value={input}/>
<button onClick={() => addTodo()}>追加</button>
{isLoading ?
<Loading>loading</Loading>
:
<TodoContainer>
{/* todoListという変数とdeleteTodoという関数をpropsとしてTodoコンポーネントに渡している*/}
<SubContainer>
<SubTitle>未完了</SubTitle>
<Todo todoList={todoList} deleteTodo={deleteTodo} changeTodoStatus={finishTodo} type="todo"/>
</SubContainer>
<SubContainer>
<SubTitle>完了済み</SubTitle>
<Todo todoList={finishedList} deleteTodo={deleteFinishTodo} changeTodoStatus={reopenTodo} type="done"/>
</SubContainer>
</TodoContainer>
}
</div>
);
};

export default App;

const Title = styled.p`
font-size: 26px;
color: #0097a7;
letter-spacing: 2.8px;
font-weight: 200;
`
;

const SubTitle = styled.p`
font-size: 22px;
color: #5c5c5c;
`
;

const SubContainer = styled.div`
width: 400px;
`
;

const TodoContainer = styled.div`
display: flex;
flex-direction: row;
width: 80%;
margin: 0 auto;
justify-content: space-between;
`
;

const Loading = styled.div`
margin: 40px auto;
`
;


これでリロードしてもデータが消えなくなりました😇

やったね😂

Screen Recording 2019-09-01 at 0.39.23.mov.gif


firebaseにデプロイしてみる

今のままではローカル環境でしか動かないのでfirebaseにホスティングさせて外部からも見れるようにしてみましょう

プロジェクトの配下で操作してください

> firebase init

hostingを選択してスペースを押す→Enter

image.png

Use an exsisting Projectを選択

image.png

先ほど作成したプロジェクトを選択

image.png

What do you want to use as your public directory?buildと入力してEnter

Configure as a single-page app (rewrite all urls to /index.html)?yと入力してEnter

image.png

こうなればOK

あとはビルドしてデプロイするだけ!

> yarn build

> firebase deploy

URLが表示されるのでアクセスすれば完成!!

お疲れさまでした

https://todolist-bc9ed.firebaseapp.com/

firebaseのログイン機能を使用してユーザー登録をすればユーザー毎のTodoを保存できるようになるのでぜひ挑戦してみてください〜!


質問&カスタマイズタイム


おまけ

Material UIを使ってちょっとマシなデザインにしてみよう

> yarn add @material-ui/core

> yarn add @material-ui/icons


App.js

import React, { useState, useEffect} from 'react'; // 修正

import TextField from '@material-ui/core/TextField';
import Button from '@material-ui/core/Button';
import './App.css';
import Todo from './pages/Todo';
import styled from 'styled-components';
import firebase from 'firebase'; // 追記
import 'firebase/firestore'; // 追記

function App() {

// 第1変数がstate, 第2変数がstateを変化させる関数
const [input, setInput] = useState('');
const [todoList, setTodoList] = useState([]);
const [finishedList, setFinishedList] = useState([]);
// Loadingを判定する変数
const [isLoading, setIsLoading] = useState(true);
// 未完了のTodoが変化したかを監視する変数
const [isChangedTodo, setIsChangedTodo] = useState(false);
// 完了済みのTodoが変化したかを監視する変数
const [isChangedFinished, setIsChangedFinished] = useState(false);

const db = firebase.firestore(); // 追記

// 追記 一番最初にfirestoreからデータを取ってきてstateに入れる
useEffect(() => {
(async () => {
const resTodo = await db.collection("todoList").doc("todo").get();
// stateに入れる
setTodoList(resTodo.data().tasks);
const resFinishedTodo = await db.collection("todoList").doc("finishedTodo").get();
// stateに入れる
setFinishedList(resFinishedTodo.data().tasks);
// Loading終了
setIsLoading(false);
})()
}, [db])

useEffect(() => {
if (isChangedTodo) {
(async () => {
// 通信をするのでLoadingをtrue
setIsLoading(true)
const docRef = await db.collection('todoList').doc('todo');
docRef.update({ tasks: todoList })
// Loading終了
setIsLoading(false)
})()
}
}, [todoList, isChangedTodo, db])

useEffect(() => {
if (isChangedFinished) {
(async () => {
// 通信をするのでLoadingをtrue
setIsLoading(true)
const docRef = await db.collection('todoList').doc('finishedTodo');
docRef.update({ tasks: finishedList })
// Loading終了
setIsLoading(false)
})()
}
setIsChangedFinished(false)
}, [db, finishedList, isChangedFinished])

const addTodo = async () => {
if (!!input) {
// 追記 Todoが変化したのでtrue
setIsChangedTodo(true);
setTodoList([...todoList, input]);
setInput('');
}
}

const deleteTodo = (index) => {
// 追記 Todoが変化したのでtrue
setIsChangedTodo(true);
setTodoList(todoList.filter((_, idx) => idx !== index))
}

const deleteFinishTodo = (index) => {
// 追記 完了済みTodoが変化したのでtrue
setIsChangedFinished(true);
setFinishedList(finishedList.filter((_, idx) => idx !== index))
}

const finishTodo = (index) => {
// 追記 Todo、完了済みTodoがともに変化したのでtrue
setIsChangedTodo(true);
setIsChangedFinished(true);
deleteTodo(index)
setFinishedList([...finishedList,todoList.find((_, idx) => idx === index)])
}

const reopenTodo = (index) => {
// 追記 Todo、完了済みTodoがともに変化したのでtrue
setIsChangedTodo(true);
setIsChangedFinished(true);
deleteFinishTodo(index)
setTodoList([...todoList,finishedList.find((_, idx) => idx === index)])
}

return (
<div className="App">
<Title>Todoリスト</Title>
<TextField onChange={(e) => setInput(e.target.value)} value={input}/>
<Button variant="contained" color="primary" onClick={() => addTodo()}>追加</Button>
{isLoading ?
<Loading>loading</Loading>
:
<TodoContainer>
{/* todoListという変数とdeleteTodoという関数をpropsとしてTodoコンポーネントに渡している*/}
<SubContainer>
<SubTitle>未完了</SubTitle>
<Todo todoList={todoList} deleteTodo={deleteTodo} changeTodoStatus={finishTodo} type="todo"/>
</SubContainer>
<SubContainer>
<SubTitle>完了済み</SubTitle>
<Todo todoList={finishedList} deleteTodo={deleteFinishTodo} changeTodoStatus={reopenTodo} type="done"/>
</SubContainer>
</TodoContainer>
}
</div>
);
};

export default App;

const Title = styled.p`
font-size: 26px;
color: #0097a7;
letter-spacing: 2.8px;
font-weight: 200;
`
;

const SubTitle = styled.p`
font-size: 22px;
color: #5c5c5c;
`
;

const SubContainer = styled.div`
width: 400px;
`
;

const TodoContainer = styled.div`
display: flex;
flex-direction: row;
width: 80%;
margin: 0 auto;
justify-content: space-between;
`
;

const Loading = styled.div`
margin: 40px auto;
`
;


Todo/index.jsx


index.jsx

import React from 'react';

import styled from 'styled-components';
import Button from '@material-ui/core/Button';
import IconButton from '@material-ui/core/IconButton';
import DeleteIcon from '@material-ui/icons/Delete';

// todoListという変数とdeleteTodoという関数をpropsとして受け取る
const Todo = ({todoList, deleteTodo, changeTodoStatus, type}) => (
<div>
{/*受け取ったtodoListを使って表示する*/}
{todoList.map((todo, idx) => (
<Container key={todo}>
{todo}
<IconButton aria-label="delete">
<DeleteIcon fontSize="small" onClick={() => deleteTodo(idx)} />
</IconButton>
<Button variant="outlined" color={type === "todo" ? "primary" : "secondary"} onClick={() => changeTodoStatus(idx)}>{type === "todo" ? "完了済みにする" : "戻す"}</Button>
</Container>
))}
</div>
);

export default Todo;

const Container = styled.div`
color: #5c5c5c;
letter-spacing: 1.8px;
`
;


ちょっとマシになったよね?笑

image.png

ちょっと頑張ればこんな感じにも(まだダサいのは許して)

output-palette-none2.gif

完成品

https://todolist-bc9ed.web.app/

ソースはここにおいておきます

ログイン機能とか作りたくてもわからんって人は気軽に言ってね😊

https://github.com/mr04vv/ReactTodoHandsOn/tree/custom