41
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-09-08

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

41
35
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?