GoogleKeep似アプリをReactで作ってみた

# はじめに

今月から自社開発系のフロントエンジニアとして働いています！
前職はSIerで、Reactは使ったことがありませんでした。そこで、入社前にReactの練習がてら、Google Keepを模したメモアプリを作ってみました！


<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">Reactで作ったgoogle keepを模倣したアプリ、一旦これにて作成done🥰（機能はちょっと中途半端かもだけどw）json-server使ってるから、ちゃんと入力/修正データも保持されるんだよ〜☺️</p>&mdash; まいちゃん (@maaaaaiiiisan) <a href="https://twitter.com/maaaaaiiiisan/status/1174334126498779136">2019年9月18日</a></blockquote>

このメモアプリの機能は、
・　メモ作成
・　作成されたメモをjson-serverに保存
・　作成されたメモの文章・色の編集
・　メモ削除
です。
非同期やpropsの理解もできるので、React初心者のチュートリアルとして参考にしてください。


# 環境構築

### プロジェクト作成
以下コマンドで、reactのプロジェクトを作成します。

```console
$ npx create-react-app my-app
```

`$ create-react-app`とは、Reactのアプリケーションの雛形を作れるコマンドラインツールです。`my-app`はプロジェクトの名前になるので適宜変更してください。
プロジェクトが作成できたら以下コマンドでプロジェクトを動かします。

```console
$ cd my-app
$ npm start
```

以下の画面が出てくれば成功です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88533/960331e9-c581-b94f-3dcf-1e3badd7ec10.png)

さて、今回は[Material Icon](https://material.io/resources/icons/?style=baseline)を使うのでそのセットアップとして、以下の`<link>`を`public/index.html`の12行目に追加してください。

```public/index.html

<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">

```

### json-serverインストールと設定

後ほど使うjson-serverをインストールしておきます。json-serverとは、jsonを使うことでバックエンドの開発不要でAPIが使えるAPIモックアップです。

```
$ npm install -g json-server
```

そして、プロジェクト直下にdb.jsonを作成します。

```db.json
{
    "tasks": [
      {
        "content": "Qiita！",
        "color": "#f28b82",
        "id": 2
      }
    ]
  }
```

json-serverの実行コマンドは以下です。今から起動しておいてもOKです。起動していないと、これから作るアプリは正常な動きをしません。

```console
$ json-server --watch db.json
```


# メモアプリのフォルダ構成
```
├── App.js
├── App.scss
├── components
│   ├── Card
│   │   ├── Card.js
│   │   ├── Card.scss
│   │   └── index.js
│   └── Form
│       ├── Form.js
│       ├── Form.scss
│       └── index.js
├── icon.png
├── index.js
├── index.scss
└── serviceWorker.js

```

- Formフォルダ

Formは、作成するメモを入力する部分です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88533/5681a762-3d79-1a43-9624-db2940e470bc.png)

- Cardフォルダ

Cardとは、作成したメモ部分です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88533/e36aba70-22df-30a6-7dc3-43e2cc6c4c6e.png)

- 補足

今回はindex.jsをFormとCardフォルダ配下にそれぞれ作成しています。これは、App.jsでFormとCardをimportする際、
```import Card from "./components/Card/Card.js";```ではなく、
```import Card from "./components/Card/Card";```と書くことができるためです。

# メモアプリの実装

さて、ここからは実装に入ります。

### 不要ファイル・不要ソースコードの削除

不要ファイルは以下なので削除します。
```App.css```   ←あとでApp.scssを作成します
```App.test.js
logo.svg
serviceWorker.js
```

コンパイルエラーが出てくると思いますが、エラー文に書かれてることを対処すればOKです。
例えば、下の画像のエラーだったら、エディタのプロジェクト内検索で、serviceWorkerを検索し、該当箇所を削除します。他の削除した要素についても同様に、プロジェクト内検索をし該当箇所を削除します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88533/14940d8d-72d6-1299-a02d-e2b021561dec.png)



### 　App.jsとApp.scssの作成

まず、App.jsとApp.scssを作成します。
App.jsは一番始めに読み込まれるjsなので、ここにページ表示させる土台を書いていきます。


App.jsに出てくる`fetchTasks` `changeText` `addMemo` `deleteMemo` `editMemo` は、これから作るForm.jsとCard.jsそれぞれで使うため、App.jsにて定義をしています。（App.jsに定義することで、Form.jsとCard.jsの両方で処理を記述する必要がありません。App.jsからFormとCardに`props`を引き渡しさえすればOKです。）また、`onClick`イベントで`this`を用いて処理が走りますが、その際に`this`が使えるように、constructor内で`bind(this)`を行います。

`async`を使った非同期処理については、`componentDidMount()`で`fetchTasks()`を呼び、db.jsonにあるデータを取ってきます。そして`AddMemo()`で`method`を`POST`にしてdb.jsonを更新しています。


```jsx:src/App.js
import React from "react";
import "./App.scss";
import Form from "./components/Form/Form";
import Card from "./components/Card/Card";
import icon from "./icon.png";
import ReactDOM from "react-dom";

class App extends React.Component {
  constructor() {
    super();
    this.fetchTasks = this.fetchTasks.bind(this);
    this.changeText = this.changeText.bind(this);
    this.addMemo = this.addMemo.bind(this);
    this.deleteMemo = this.deleteMemo.bind(this);
    this.editMemo = this.editMemo.bind(this);
    this.state = {
      showInput: false,
      black: true,
      color: null,
      tasks: []
    };
  }

  componentDidMount() {
    this.fetchTasks();
  }

  async fetchTasks() {
    await fetch("http://localhost:3000/tasks")
      .then(response => response.json())
      .then(json => {
        this.setState({ tasks: json });
      });
  }

  changeText = event => {
    const inputText = event.target.value;
    this.setState({ inputText: inputText });
  };

  changeFormColor = (color = "#ffffff") => {
    this.setState({
      color: color
    });
  };

  addMemo = () => {
    this.setState({ showInput: false });
    fetch("http://localhost:3000/tasks", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        content: this.state.inputText,
        color: this.state.color
      })
    }).then(this.fetchTasks);
  };

  deleteMemo(taskId) {
    this.setState({ showInput: false });
    fetch("http://localhost:3000/tasks/" + taskId, {
      method: "DELETE"
    }).then(this.fetchTasks);
  }

  editMemo(taskId) {
    this.setState({ showInput: true });
    let input = ReactDOM.findDOMNode(this.refs["text-cell"]);
    input && input.focus();
  }

  saveMemo = (taskId, content, color) => {
    fetch("http://localhost:3001/tasks/" + taskId, {
      method: "PUT",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json"
      },
      body: JSON.stringify({ content, color })
    });
  };

  render() {
    const list = this.state.tasks.map(task => {
      const showInput = this.state.showInput;
      let input;
      if (showInput) {
        input = (
          <input
            className="input-box"
            onChange={this.changeText}
            key={task.id}
            ref="text-cell"
          />
        );
      } else {
        input = (
          <input
            className="input-box"
            onChange={this.changeText}
            key={task.id}
            value={task.content}
          />
        );
      }

      return (
        <Card
          task={task}
          color={this.state.color}
          tasks={this.state.tasks}
          key={task.id}
          changeText={this.changeText}
          deleteMemo={this.deleteMemo}
          fetchTasks={this.fetchTasks}
          saveMemo={this.saveMemo}
        />
      );
    });
    return (
      <div>
        <div className="header">
          <i className="material-icons">menu</i>
          <img src={icon} className="icon" alt="" />
          <h2>Keep</h2>
        </div>
        <Form
          changeFormColor={this.changeFormColor}
          color={this.state.color}
          tasks={this.state.tasks}
          saveMemo={this.saveMemo}
          changeText={this.changeText}
          addMemo={this.addMemo}
        />
        <div className="list-container">
          <ul>{list}</ul>
        </div>
      </div>
    );
  }
}

export default App;
```

```src/App.scss
.icon {
  width: 40px;
  height: 40px;
  display: inline-block;
}

.header {
  display: flex;
  align-items: center;
  width: 100%;
  margin: 1rem 0;
  border-bottom: solid rgba(0, 0, 0, 0.1);
  border-width: 1px 1px 1px 6px;
}
h2 {
  display: inline-block;
  text-align: center;
  color: #5f6368;
  padding-left: 4px;
  font-size: 22px !important;
  line-height: 24px;
}
i {
  margin: 0 4px;
  padding: 12px;
}

.App {
  text-align: center;
}

.App-logo {
  animation: App-logo-spin infinite 20s linear;
  height: 40vmin;
  pointer-events: none;
}

.App-header {
  background-color: #282c34;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

.App-link {
  color: #61dafb;
}

@keyframes App-logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.purple {
  color: #d7aefb;
}
.pink {
  color: #f28b82;
}

ul {
  list-style: none;
  width: 70%;
  padding-left: 0;
}

.list-container {
  justify-content: center;
  align-items: center;
  margin: 40px 130px 16px 130px;
}
button {
  background-color: transparent;
  border: none;
  cursor: pointer;
  outline: none;
  padding: 0;
  appearance: none;
  :focus {
    outline: none;
  }
}
.input-box {
  width: 100%;
}

```

### 　Form.jsとForm.scssの作成

Formでは、`App.js`で引き渡されてきた`props`を多く使っています。


```jsx:src/Components/Form/Form.js
import React from "react";
import "./Form.scss";

class Form extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            content: ""
        };
    }

    render() {
        return (
            <div className="form" style={{ backgroundColor: this.props.color }}>
                <div className="form-top">
                    <input
                        className="input-box"
                        placeholder="メモを入力..."
                        onChange={this.props.changeText}
                        style={{ backgroundColor: this.props.color }}
                    />
                </div>
                <div className="form-bottom">
                    <button onClick={() => this.props.changeFormColor()}>
                        <i className="material-icons">color_lens</i>
                    </button>
                    <button onClick={() => this.props.changeFormColor("#f28b82")}>
                        <i className="material-icons pink">color_lens</i>
                    </button>
                    <button onClick={() => this.props.changeFormColor("#d7aefb")}>
                        <i className="material-icons purple">color_lens</i>
                    </button>
                    <button
                        className="input-close"
                        type="submit"
                        onClick={this.props.addMemo}
                    >
                        作成
                    </button>
                </div>
            </div>
        );
    }
}
export default Form;

```

```src/Components/Form/Form.scss
.form {
    justify-content: center;
    align-items: center;
    margin: 40px 130px 16px 130px;
    box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.302), 0 2px 6px 2px rgba(60, 64, 67, 0.149);
    border-radius: 12px;

    .form-top {
        margin: 1rem;
    }

    .form-bottom {
        margin: 1rem;
    }

    .input-box {
        padding: 4px 16px 12px;
        font-size: 0.875rem;
        height: 5rem;
        border: none;
        display: flex;
        width: 100%;
    }

    .input-close {
        float: right;
        border: none;
        padding: 8px 24px;
        font-size: 14px;
    }
}
input:focus {
    outline: none;
}

```

### Card.jsとCard.scssの作成

ポイントは`isEditMode`です。`false`を初期値としますが、☑️を押下すると`toggleEditMode()`が呼ばれ、`toggleEditMode()`で`true`にします。`true`にすることで、`done`のボタンが表示されます。

```jsx:src/Components/Card/Card.js
import React from "react";
import './Card.scss';

class Card extends React.Component {
    state = {
        isEditMode: false,
        content: this.props.task.content,
        color: this.props.task.color,
        tasks: this.props.tasks,
        task: this.props.task
    }

    toggleEditMode = () => {
        this.setState({
            isEditMode: !this.state.isEditMode
        })
    }

    handleEditDone = () => {
        this.setState({
            isEditMode: false,
        })
        this.props.saveMemo(this.props.task.id, this.state.content, this.state.color);
    }

    changeText = (e) => {
        this.setState({
            content: e.target.value
        });
    }

    changeColor = (color = "#ffffff") => {
        this.setState({
            color: color
        });
        this.props.saveMemo(this.props.task.id, this.state.content, this.state.color);
    }

    render() {
        return (
            <li className="list" key={this.props.task.id} style={{ backgroundColor: this.state.color }}>
                {this.state.isEditMode ?
                    <input
                        className="input-box"
                        onChange={this.changeText}
                        key={this.props.task.id}
                        ref="text-cell"
                        autoFocus={true}
                        value={this.state.content}
                    />
                    :
                    <p>{this.state.content}</p>
                }
                <div className="list-buttom">
                    <button onClick={this.toggleEditMode}><i className="material-icons">edit</i></button>
                    {this.state.isEditMode &&
                        <button onClick={this.handleEditDone}><i className="material-icons">done</i></button>
                    }
                    <button onClick={() => this.changeColor()} ><i className="material-icons">color_lens</i></button>
                    <button onClick={() => this.changeColor('#f28b82')}><i className="material-icons pink">color_lens</i></button>
                    <button onClick={() => this.changeColor('#d7aefb')}><i className="material-icons purple">color_lens</i></button>
                    <button onClick={() => this.props.deleteMemo(this.props.task.id)}><i className="material-icons">delete</i></button>
                </div>
            </li>
        );
    }
}
export default Card;

```

```src/Components/Card/Card.scss
.list {
    padding: 16px 16px 1px 16px;
    box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.302), 0 2px 6px 2px rgba(60, 64, 67, 0.149);
    border-radius: 12px;
    border-width: 1px;
    margin: 16px 16px 16px 0px;
    width: 100%;
}
.list-bottom {
    display: block;
}
li {
    border-radius: 10px;
    border: solid 3px #e0e0e0;
    min-height: 5rem;
}

```

# おわりに

このGoogleKeep模倣アプリは、3~4日間で完成できました。
作りながら色々調べたり、自分自身で手を動かしたりすることで、Reactの基礎的なことは理解できるので是非3連休などで参考にして作ってみてください！
githubのレポジトリは[こちら](https://github.com/maaaaaiiiisan/keep-app)なので何かあった時には参考にしてください。
またこうするといいよ、などのコメントも大歓迎です！

# 参考文献
[React公式ページ](https://create-react-app.dev/docs/getting-started)