はじめに
今月から自社開発系のフロントエンジニアとして働いています!
前職はSIerで、Reactは使ったことがありませんでした。そこで、入社前にReactの練習がてら、Google Keepを模したメモアプリを作ってみました!
Reactで作ったgoogle keepを模倣したアプリ、一旦これにて作成done🥰(機能はちょっと中途半端かもだけどw)json-server使ってるから、ちゃんと入力/修正データも保持されるんだよ〜☺️
— まいちゃん (@maaaaaiiiisan) 2019年9月18日
このメモアプリの機能は、
・ メモ作成
・ 作成されたメモをjson-serverに保存
・ 作成されたメモの文章・色の編集
・ メモ削除
です。
非同期やpropsの理解もできるので、React初心者のチュートリアルとして参考にしてください。
環境構築
プロジェクト作成
以下コマンドで、reactのプロジェクトを作成します。
$ npx create-react-app my-app
$ create-react-app
とは、Reactのアプリケーションの雛形を作れるコマンドラインツールです。my-app
はプロジェクトの名前になるので適宜変更してください。
プロジェクトが作成できたら以下コマンドでプロジェクトを動かします。
$ cd my-app
$ npm start
さて、今回はMaterial Iconを使うのでそのセットアップとして、以下の<link>
をpublic/index.html
の12行目に追加してください。
<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を作成します。
{
"tasks": [
{
"content": "Qiita!",
"color": "#f28b82",
"id": 2
}
]
}
json-serverの実行コマンドは以下です。今から起動しておいてもOKです。起動していないと、これから作るアプリは正常な動きをしません。
$ 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フォルダ
- Cardフォルダ
- 補足
今回は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を作成します
logo.svg
serviceWorker.js
コンパイルエラーが出てくると思いますが、エラー文に書かれてることを対処すればOKです。
例えば、下の画像のエラーだったら、エディタのプロジェクト内検索で、serviceWorkerを検索し、該当箇所を削除します。他の削除した要素についても同様に、プロジェクト内検索をし該当箇所を削除します。
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を更新しています。
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;
.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
を多く使っています。
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;
.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
のボタンが表示されます。
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;
.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のレポジトリはこちらなので何かあった時には参考にしてください。
またこうするといいよ、などのコメントも大歓迎です!