ReactでTrelloみたいなToDoリスト<4> immutability-helper

More than 1 year has passed since last update.


関連記事


外部APIとの通信、Props Callback function

今までは外観を作っただけで、カードの操作はできなかった。

カードの操作ができるように機能を追加してみよう。

また著者のサイトから学習用のAPIサーバーを提供しているので、そのサーバーと通信する形で改善する。

カードを操作する機能については、親コンポーネントからfunctionを作り、Props Callbackとして子コンポーネントへ渡す。


src/index.js

diff --git a/src/index.js b/src/index.js

index dd395b8..6e38759 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom';
-import KanbanBoard from './components/KanbanBoard';
+import KanbanBoardContainer from './components/KanbanBoardContainer';
import registerServiceWorker from './registerServiceWorker';
import './index.css';

@@ -39,5 +39,5 @@ const cardsList = [
},
];

-ReactDOM.render(<KanbanBoard cards={cardsList} />, document.getElementById('root'));
+ReactDOM.render(<KanbanBoardContainer cards={cardsList} />, document.getElementById('root'));
registerServiceWorker();


外部APIとの通信でカードリストを取ってくるのでcardsListという仮のデータは要らないが、そのまま置いて進む。

もちろん消しても構わない。

ここからが本番だ。

カードを操作するためにReactから提供しているimmutability-helperを使う。

本でreact-addons-updateを紹介しているが、”legacyだから代わりにimmutability-helperを使ってくれ”と公式ドキュメントに書かれていた。

immutability-helperを設置してみよう。

yarn add immutability-helper

使い方はこんな感じ

import update from 'immutability-helper';

const state1 = ['x'];
const state2 = update(state1, {$push: ['y']}); // ['x', 'y']

state1とstate2は別のオブジェクトだ。

updateは対象objectのコピーを作ってそのコピーを更新する。

state2はstate1をコピーして作っているが、別のobjectなので、state2に変更があってもstate1には影響を及ぼさない。

immutability-helperの有効なコマンドを整理した。

githubにあるサンプルコードをそのまま持ってきた。

見ればわかると思うが、arrayとほぼ同じだ。

すこしややこしいところは補足したので、参考にしよう。


  • {$push: array}

const initialArray = [1, 2, 3];

const newArray = update(initialArray, {$push: [4]}); // => [1, 2, 3, 4]


  • {$unshift: array}

const initialArray = [1, 2, 3];

const newArray = update(initialArray, {$unshift: [0]}); // => [0, 1, 2, 3]


  • {$splice: array of arrays}

const collection = [1, 2, {a: [12, 17, 15]}];

const newCollection = update(collection, {2: {a: {$splice: [[1, 1, 13, 14]]}}});
// => [1, 2, {a: [12, 13, 14, 15]}]

:bulb: 補足

collectionのindexが2、keyがaでitemのスタートindexが1、そこから1個を消して、13と14を追加する。


  • {\$set: any}、{\$apply: function}

const obj = {a: 5, b: 3};

const newObj = update(obj, {b: {$apply: function(x) {return x * 2;}}});
// => {a: 5, b: 6}
// This is equivalent, but gets verbose for deeply nested collections:
const newObj2 = update(obj, {b: {$set: obj.b * 2}});

:bulb: 補足

結果としてnewObjとnewObj2は同じだ。値としてapplyは関数を、setは式になっている。


  • {$merge: object}

const obj = {a: 5, b: 3};

const newObj = update(obj, {$merge: {b: 6, c: 7}}); // => {a: 5, b: 6, c: 7}


src/components/KanbanBoardContainer.js

diff --git a/src/components/KanbanBoardContainer.js b/src/components/KanbanBoardContainer.js

new file mode 100644
index 0000000..3197efd
--- /dev/null
+++ b/src/components/KanbanBoardContainer.js
@@ -0,0 +1,142 @@
+import React, { Component } from 'react';
+import update from 'immutability-helper';
+import 'whatwg-fetch';
+import KanbanBoard from './KanbanBoard';
+
+const API_URL = 'http://kanbanapi.pro-react.com';
+const API_HEADERS = {
+ 'Content-Type': 'application/json',
+ Authorization: 'CHANGE THIS VALUE',
+};
+
+class KanbanBoardContainer extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ cards: [],
+ };
+ this.addTask = this.addTask.bind(this);
+ this.deleteTask = this.deleteTask.bind(this);
+ this.toggleTask = this.toggleTask.bind(this);
+ }
+
+ componentDidMount() {
+ fetch(`${API_URL}/cards`, { headers: API_HEADERS })
+ .then(response => response.json())
+ .then(responseData => this.setState({ cards: responseData }))
+ .catch(error => console.error('Error fetching and parsing data', error));
+ }
+
+ addTask(cardId, taskName) {
+ const prevState = this.state;
+ const cardIndex = this.state.cards.findIndex(card => card.id === cardId);
+ const newTask = { id: Date.now(), name: taskName, done: false };
+ const nextState = update(this.state.cards, {
+ [cardIndex]: {
+ tasks: { $push: [newTask] },
+ },
+ });
+
+ this.setState({ cards: nextState });
+
+ fetch(`${API_URL}/cards/${cardId}/tasks`, {
+ method: 'post',
+ headers: API_HEADERS,
+ body: JSON.stringify(newTask),
+ })
+ .then((response) => {
+ if (response.ok) {
+ return response.json();
+ }
+ throw new Error("Server response wasn't OK");
+ })
+ .then((responseData) => {
+ newTask.id = responseData.id;
+ this.setState({ cards: nextState });
+ })
+ .catch(() => {
+ this.setState(prevState);
+ });
+ }
+
+ deleteTask(cardId, taskId, taskIndex) {
+ const cardIndex = this.state.cards.findIndex(card => card.id === cardId);
+ const prevState = this.state;
+ const nextState = update(this.state.cards, {
+ [cardIndex]: {
+ tasks: { $splice: [[taskIndex, 1]] },
+ },
+ });
+
+ this.setState({ cards: nextState });
+
+ fetch(`${API_URL}/cards/${cardId}/tasks/${taskId}`, {
+ method: 'delete',
+ headers: API_HEADERS,
+ })
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error("Server response wasn't OK");
+ }
+ })
+ .catch((error) => {
+ console.error('Fetch error:', error);
+ this.setState(prevState);
+ });
+ }
+
+ toggleTask(cardId, taskId, taskIndex) {
+ const prevState = this.state;
+ const cardIndex = this.state.cards.findIndex(card => card.id === cardId);
+ let newDoneValue;
+ const nextState = update(
+ this.state.cards,
+ {
+ [cardIndex]: {
+ tasks: {
+ [taskIndex]: {
+ done: {
+ $apply: (done) => {
+ newDoneValue = !done;
+ return newDoneValue;
+ },
+ },
+ },
+ },
+ },
+ },
+ );
+
+ this.setState({ cards: nextState });
+
+ fetch(`${API_URL}/cards/${cardId}/tasks/${taskId}`, {
+ method: 'put',
+ headers: API_HEADERS,
+ body: JSON.stringify({ done: newDoneValue }),
+ })
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error('Server response wasn\'t OK');
+ }
+ })
+ .catch((error) => {
+ console.error('Fetch error:', error);
+ this.setState(prevState);
+ });
+ }
+
+ render() {
+ return (
+ <KanbanBoard
+ cards={this.state.cards}
+ taskCallbacks={{
+ toggle: this.toggleTask,
+ delete: this.deleteTask,
+ add: this.addTask,
+ }}
+ />
+ );
+ }
+}
+
+export default KanbanBoardContainer;

ソースコードが長く見えるが、外部APIと通信しながら、タスクを追加、削除、トグルするため関連関数を追加しただけだ。

componentDidMountはコンポーネントがマウント(レンダーリング)された直後に必ず1回呼び出される関数だ。

Promiseオブジェクトについては割愛する。

APIサーバーとのやりとりを行う際は、fetch~then~catch順でするものだと覚えておく。

使ううちにわかるようになる。


react/forbid-prop-types

PropTypesにてany、array、objectはeslintで使用が禁止されている。

確かに、どんなタイプなのか明らかにしないとPropTypesを使う意味がない。

arrayの代わりにarrayOfを、objectの代わりにshapeを使おう。

詳細は、PropTypesのValidatorで確認しよう。


src/components/KanbanBoard.js

diff --git a/src/components/KanbanBoard.js b/src/components/KanbanBoard.js

index b89409d..8ee231e 100644
--- a/src/components/KanbanBoard.js
+++ b/src/components/KanbanBoard.js
@@ -9,6 +9,7 @@ class KanbanBoard extends Component {
<List
id="todo"
title="To Do"
+ taskCallbacks={this.props.taskCallbacks}
cards={
this.props.cards.filter(card => card.status === 'todo')
}
@@ -16,6 +17,7 @@ class KanbanBoard extends Component {
<List
id="in-progress"
title="In Progress"
+ taskCallbacks={this.props.taskCallbacks}
cards={
this.props.cards.filter(card => card.status === 'in-progress')
}
@@ -23,6 +25,7 @@ class KanbanBoard extends Component {
<List
id="done"
title="Done"
+ taskCallbacks={this.props.taskCallbacks}
cards={
this.props.cards.filter(card => card.status === 'done')
}
@@ -34,6 +37,11 @@ class KanbanBoard extends Component {

KanbanBoard.propTypes = {
cards: PropTypes.arrayOf(PropTypes.object).isRequired,
+ taskCallbacks: PropTypes.shape({
+ toggle: PropTypes.func,
+ delete: PropTypes.func,
+ add: PropTypes.func,
+ }).isRequired,
};

export default KanbanBoard;



src/components/List.js

diff --git a/src/components/List.js b/src/components/List.js

index c548d80..2ba25ee 100644
--- a/src/components/List.js
+++ b/src/components/List.js
@@ -7,6 +7,7 @@ class List extends Component {
const cards = this.props.cards.map(card =>
(<Card
key={card.id}
+ taskCallbacks={this.props.taskCallbacks}
id={card.id}
title={card.title}
description={card.description}
@@ -27,6 +28,11 @@ class List extends Component {
List.propTypes = {
title: PropTypes.string.isRequired,
cards: PropTypes.arrayOf(PropTypes.object).isRequired,
+ taskCallbacks: PropTypes.shape({
+ toggle: PropTypes.func,
+ delete: PropTypes.func,
+ add: PropTypes.func,
+ }).isRequired,
};

export default List;



src/components/Card.js

diff --git a/src/components/Card.js b/src/components/Card.js

index 5025f5f..19e0f4c 100644
--- a/src/components/Card.js
+++ b/src/components/Card.js
@@ -22,7 +22,11 @@ class Card extends Component {
cardDetails = (
<div className="card__details">
<span dangerouslySetInnerHTML={{ __html: Marked(this.props.description) }} />
- <CheckList cardId={this.props.id} tasks={this.props.tasks} />
+ <CheckList
+ cardId={this.props.id}
+ tasks={this.props.tasks}
+ taskCallbacks={this.props.taskCallbacks}
+ />
</div>
);
}
@@ -81,6 +85,11 @@ Card.propTypes = {
description: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
tasks: PropTypes.arrayOf(PropTypes.object).isRequired,
+ taskCallbacks: PropTypes.shape({
+ toggle: PropTypes.func,
+ delete: PropTypes.func,
+ add: PropTypes.func,
+ }).isRequired,
};

export default Card;



src/components/CheckList.js

diff --git a/src/components/CheckList.js b/src/components/CheckList.js

index 4de883f..bd1b701 100644
--- a/src/components/CheckList.js
+++ b/src/components/CheckList.js
@@ -2,12 +2,37 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';

class CheckList extends Component {
+ constructor(props) {
+ super(props);
+ this.checkInputKeyPress = this.checkInputKeyPress.bind(this);
+ }
+
+ checkInputKeyPress(evt) {
+ const event = evt;
+ if (event.key === 'Enter') {
+ this.props.taskCallbacks.add(this.props.cardId, event.target.value);
+ event.target.value = '';
+ }
+ }
+
render() {
- const tasks = this.props.tasks.map(task => (
+ const tasks = this.props.tasks.map((task, taskIndex) => (
<li key={task.id} className="checklist__task">
- <input type="checkbox" defaultChecked={task.done} />
+ <input
+ type="checkbox"
+ checked={task.done}
+ onChange={
+ this.props.taskCallbacks.toggle.bind(null, this.props.cardId, task.id, taskIndex)
+ }
+ />
{task.name}{' '}
- <span className="checklist__task--remove" />
+ <span
+ className="checklist__task--remove"
+ role="presentation"
+ onClick={
+ this.props.taskCallbacks.delete.bind(null, this.props.cardId, task.id, taskIndex)
+ }
+ />
</li>
));

@@ -18,6 +43,7 @@ class CheckList extends Component {
type="text"
className="checklist--add-task"
placeholder="Type then hit Enter to add a task"
+ onKeyPress={this.checkInputKeyPress}
/>
</div>
);
@@ -25,7 +51,13 @@ class CheckList extends Component {
}

CheckList.propTypes = {
+ cardId: PropTypes.number.isRequired,
tasks: PropTypes.arrayOf(PropTypes.object).isRequired,
+ taskCallbacks: PropTypes.shape({
+ toggle: PropTypes.func,
+ delete: PropTypes.func,
+ add: PropTypes.func,
+ }).isRequired,
};

export default CheckList;


eslintから”jsx内でbindを使うな!”と怒られる。

今までconstructorでbindすることで回避していたが、ここではarrow functionを使ってみよう。

arrow functionでのthisはarrow functionが宣言された場所を指しているため、bindが要らない。


src/components/CheckList.js

diff --git a/src/components/CheckList.js b/src/components/CheckList.js

index bd1b701..d554084 100644
--- a/src/components/CheckList.js
+++ b/src/components/CheckList.js
@@ -22,7 +22,7 @@ class CheckList extends Component {
type="checkbox"
checked={task.done}
onChange={
- this.props.taskCallbacks.toggle.bind(null, this.props.cardId, task.id, taskIndex)
+ () => this.props.taskCallbacks.toggle(this.props.cardId, task.id, taskIndex)
}
/>
{task.name}{' '}
@@ -30,7 +30,7 @@ class CheckList extends Component {
className="checklist__task--remove"
role="presentation"
onClick={
- this.props.taskCallbacks.delete.bind(null, this.props.cardId, task.id, taskIndex)
+ () => this.props.taskCallbacks.delete(this.props.cardId, task.id, taskIndex)
}
/>
</li>

これで、eslintからのエラーは全部クリアした。

著者が学習用で用意したサーバーからデータを取ってくるので、すでに他の人々が入れたデータが表示される。

カードの中のタスクを追加、削除、トグルしてみよう。