Reactでアプリを作成しました【5】【Todo List ②】「自分用メモ」


はじめに

学習するに至った経緯

2020年より、未経験からエンジニアへの転職を目指し、某プログラミングスクールへ通う。入学後、『Ruby』を未経験から学ぶ人が多いのと『Ruby』の求人が思っていた以上に少ないので、卒業後、フロントエンドのエンジニアを目指す事に。
Reactの学習した事を言語化し、認識の深化による備忘録として記載。

【仕様】

  • テキスト入力欄にタスクを書き込んでエンターキーを押すと下のリストに追加される。
  • 完了したタスクは、チェックボックスを『ON』にする。視覚効果として、完了済みのタスクは文字を薄くなる。
  • タブによるフィルタリング機能もあり、『All』を選択すると全てのタスク、『ToDo』を選択すると未完了のタスクのみ、『Done』を選択すると完了済みのタスクのみが表示される。
  • 下部には表示中のタスク件数が表示される。

【コンポーネント設計する】

アプリの作成(実装)を始める前に、どうアプリを作成するのかを下記の点を踏まえ、考える。
  • どのようにコンポーネントを分けるか。
  • それぞれのコンポーネントはどのような属性(props)を受け取り、どのような状態(state)を管理するか。
※ 『フォーム』や『ボタン』『リンク』『タブ』などインタラクティブな UI 要素がある場合、『ハンドラ』が必要。 誰が『ハンドラ』を定義して、誰が実行するかを考えること。

1. 作成する『Todoアプリ』のコンポーネントを設計する

① 『Todo』コンポーネントは、アプリの全体 

➡︎ アプリの大元になるので、外部からの props は不要。

② 『Input』コンポーネントは、タスクの入力欄

➡︎ 入力欄なので、入力値を『state』として管理する。この入力値は、エンターキーが押されるまで親(Todo)が知る必要はないので、『Input』コンポーネント内で管理するが、エンターキーが押されたら、親のタスクリストを更新する必要がある。

③ 『Filter』コンポーネントは、 タブのフィルタリング部分

➡︎ 『Input』と同様、それぞれのタブが押された時のイベントハンドラ関数を『props』 として受け取る必要がある。そのほか、選択中のタブは見た目を変える必要がある。

④ 『TodoItem』 コンポーネントは、タスク一個分

➡︎ 一個分のタスク情報を親から『props』で渡してもらう必要がある。さらに、チェックボックスがあり、チェックすると、何らかの形でタスクの完了状態を更新する。『TodoItem』はタスク情報をもらうだけで、自分では管理しない。管理するのは『 Todo』で、子でイベントが発生したタイミングで、親の『state』が更新される必要があるので、『Todo』から『TodoItem』にイベントハンドラを渡すパターンが良い。言い換えるなら、『TodoItem』は、どのタスクがチェックされたか、親である『Todo』 知らせる必要がある。

※「知らせる」とは「イベント」のこと

Propsとは

Reactを基本からまとめてみた【5】【Props(プロップス)】

Stateとは

Reactを基本からまとめてみた【6】【State(ステート)】

2. HTMLを作成する

index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>⚛️ React ToDo</title>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.8.2/css/bulma.min.css" />
  <style>
    .container { margin-top: 2rem; }
  </style>
</head>
<body>
  <div id="root"></div>

  <script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
  <script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/classnames/2.2.6/index.min.js"></script>
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>

  <script type="text.js">

    // ここにコードを書いていく

  </script>
</body>
</html>

CSS フレームワーク 『Bulma』 とは

公式サイト : Bulma

『Bulma』は、 JavaScript 無しのプレーンな CSS フレームワーク。予め用意されたコンポーネントに対応するクラス名をHTML要素に付与することでスタイリングを行う。

React ライブラリ 『classnames』 とは

パッケージドキュメント : classnames
htmlではclassを使ってスタイリングをするが、Reactが使っているjsxではclassNameを使ったスタイリングを行う。

3. レンダリングされるルートコンポーネントとして『App』を作成する

text.js
function Todo() {
  return null;
}

function App() {
  return (
    <div className="container is-fluid">
      <Todo />
    </div>
  );
}

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

4. タスクを表示する機能を作成する

①Todo に以下の state を生成する。

タスクは複数存在するので、配列で表現する。

const [items, setItems] = React.useState([]);

この配列に入るのはデータは、タスクの文字列をそのまま配列に入れると、完了したかどうか分からないので、以下の形式で管理する。

{
  key: String,
  text: String,
  done: Boolean
}

key は、タスクを一意に特定する ID で、本格的なアプリだとデータベースに格納した結果の ID 値などになるが、今回は以下の関数でランダムな文字列を生成する。
この関数はコードの一番上に追加する。

const getKey = () => Math.random().toString(32).substring(2);
② state の初期値にテストデータを入れる。

その state をループで表示する JSX コードを追加する。

text.js
function Todo() {
  const [items, setItems] = React.useState([
    { key: getKey(), text: 'Learn JavaScript', done: false },
    { key: getKey(), text: 'Learn React', done: false },
    { key: getKey(), text: 'Get some good sleep', done: false },
  ]);

  return (
    <div className="panel">
      <div className="panel-heading">
        ⚛️ React ToDo
      </div>
      {items.map(item => (
        <label className="panel-block">
          <input type="checkbox" />
          {item.text}
        </label>
      ))}
      <div className="panel-block">
        {items.length} items
      </div>
    </div>
  );
}
③ タスクの部分を『TodoItem』コンポーネントに切り出す。
function TodoItem({ item }) {
  return (
    <label className="panel-block">
      <input type="checkbox" />
      {item.text}
    </label>
  );
}

TodoItem から返される JSX は以下のようになる。
ループで生成される要素には『key』が必要で、タスクに用意した『key』プロパティを利用する。

<div className="panel">
  <div className="panel-heading">
    ⚛️ React ToDo
  </div>
  {items.map(item => (
    <TodoItem key={item.key} item={item} />
  ))}
  <div className="panel-block">
    {items.length} items
  </div>
</div>

5.タスクの完了状態を切り替える

①チェックボックスで完了状態を切り替えられるようにする。

TodoItem では、チェックされた(もしくは外された)時に、ハンドラ関数 onCheck を実行する。ハンドラにはタスク情報を渡す。子から親に知らせるイメージ。

function TodoItem({ item, onCheck }) {
  const handleChange = () => {
    onCheck(item);
  };

  return (
    <label className="panel-block">
      <input
        type="checkbox"
        checked={item.done}
        onChange={handleChange}
      />
      {item.text}
    </label>
  );
}
②Todo にハンドラを実装する。

items から map で新しいリストを作成して setItems する。map の中では、key で同一判定をして、チェック対象の done の真偽を反転させる。

const handleCheck = checked => {
  const newItems = items.map(item => {
    if (item.key === checked.key) {
      item.done = !item.done;
    }
    return item;
  });
  setItems(newItems);
};
③実装したハンドラを TodoItem の onCheck props に指定する。
{items.map(item => (
  <TodoItem
    key={item.key}
    item={item}
    onCheck={handleCheck}
  />
))}
④TodoItem の JSXをBulma のヘルパークラスを用い、完了済みのタスクは文字色を灰色に変化するように編集する。
<label className="panel-block">
  <input
    type="checkbox"
    checked={item.done}
    onChange={handleChange}
  />
  <span
    className={classNames({
      'has-text-grey-light': item.done
    })}
  >
    {item.text}
  </span>
</label>
⑤ {item.text} に CSS クラスを適用させるために で囲った上で、classnames ライブラリを利用する。

※ IとIIは同じ意味

I
className={classNames({
  'has-text-grey-light': item.done // 真偽値
})}
II
className={item.done ? 'has-text-grey-light' : ''}
⑥TodoItem コンポーネントは完成。

チェックの切り替えと、ON 時の文字色の変化を確認する。

6.タスクを作成する

① 入力欄の値を管理するだけの Input コンポーネントを作成する。
function Input() {
  const [text, setText] = React.useState('');

  const handleChange = e => setText(e.target.value);

  return (
    <div className="panel-block">
      <input
        class="input"
        type="text"
        placeholder="Enter to add"
        value={text}
        onChange={handleChange}
      />
    </div>
  );
}
② エンターキーを押したときのハンドラを実装する。

onKeyDown のハンドラで一旦イベントを受け取り、エンターキーであった時のみ props で受け取る想定の onAdd を実行する。引数には入力されたテキストを渡す。さらに、追加処理の後は入力欄をクリアにする。

function Input({ onAdd }) {
  // 中略

  const handleKeyDown = e => {
    if (e.key === 'Enter') {
      onAdd(text);
      setText('');
    }
  };

  return (
    <div className="panel-block">
      <input
        class="input"
        type="text"
        placeholder="Enter to add"
        value={text}
        onChange={handleChange}
        onKeyDown={handleKeyDown}
      />
    </div>
  );
}
⑤Todo に、追加処理を行うハンドラ関数を実装する。

テキストは子から渡されるので、key と done を追加して、タスクリストに追加する。

const handleAdd = text => {
  setItems([...items, { key: getKey(), text, done: false }]);
};
⑥.panel-heading の下に Input を配置する。
<Input onAdd={handleAdd} />

7.フィルタリング機能を作成する

①Todo に state を追加する。

フィルタリング条件は、ALL / TODO / DONE の文字列で、今回は表現する。

const [filter, setFilter] = React.useState('ALL');
② Filter コンポーネントを実装する。
function Filter({ value, onChange }) {
  const handleClick = (key, e) => {
    e.preventDefault();
    onChange(key);
  };

  return (
    <div className="panel-tabs">
      <a
        href="#"
        onClick={handleClick.bind(null, 'ALL')}
      >All</a>
      <a
        href="#"
        onClick={handleClick.bind(null, 'TODO')}
      >ToDo</a>
      <a
        href="#"
        onClick={handleClick.bind(null, 'DONE')}
      >Done</a>
    </div>
  );
}

bind() メソッドとは

bind() メソッドは、呼び出された際に this キーワードに指定された値が設定される新しい関数を生成。この値は新しい関数が呼び出された時、一連の引数の前に置かれる。

③Todo に戻り、フィルタリング条件を更新する関数を作成する。
const handleFilterChange = value => setFilter(value);
④items を直接表示するのではなく、条件に応じてフィルタリングされた結果を表示する。

以下のコードを Todo に追加する。

const displayItems = items.filter(item => {
  if (filter === 'ALL') return true;
  if (filter === 'TODO') return !item.done;
  if (filter === 'DONE') return item.done;
});

⑤タスク表示箇所と件数表示箇所を displayItems を使うように編集する。
displayItems.map(item => (
  // 中略
))}
<div className="panel-block">
  {displayItems.length} items
</div>
⑥タブの表示切り替えを実装する。

classnames を使用する。

<a
  href="#"
  onClick={handleClick.bind(null, 'ALL')}
  className={classNames({ 'is-active': value === 'ALL' })}
>All</a>
<a
  href="#"
  onClick={handleClick.bind(null, 'TODO')}
  className={classNames({ 'is-active': value === 'TODO' })}
>ToDo</a>
<a
  href="#"
  onClick={handleClick.bind(null, 'DONE')}
  className={classNames({ 'is-active': value === 'DONE' })}
>Done</a>

7.完成

const getKey = () => Math.random().toString(32).substring(2);

function Todo() {
  const [items, setItems] = React.useState([]);
  const [filter, setFilter] = React.useState('ALL');

  const handleAdd = text => {
    setItems([...items, { key: getKey(), text, done: false }]);
  };

  const handleFilterChange = value => setFilter(value);

  const displayItems = items.filter(item => {
    if (filter === 'ALL') return true;
    if (filter === 'TODO') return !item.done;
    if (filter === 'DONE') return item.done;
  });

  const handleCheck = checked => {
    const newItems = items.map(item => {
      if (item.key === checked.key) {
        item.done = !item.done;
      }
      return item;
    });
    setItems(newItems);
  };

  return (
    <div className="panel">
      <div className="panel-heading">
        ⚛️ React ToDo
      </div>
      <Input onAdd={handleAdd} />
      <Filter
        onChange={handleFilterChange}
        value={filter}
      />
      {displayItems.map(item => (
        <TodoItem
          key={item.text}
          item={item}
          onCheck={handleCheck}
         />
      ))}
      <div className="panel-block">
        {displayItems.length} items
      </div>
    </div>
  );
}

function Input({ onAdd }) {
  const [text, setText] = React.useState('');

  const handleChange = e => setText(e.target.value);

  const handleKeyDown = e => {
    if (e.key === 'Enter') {
      onAdd(text);
      setText('');
    }
  };

  return (
    <div className="panel-block">
      <input
        class="input"
        type="text"
        placeholder="Enter to add"
        value={text}
        onChange={handleChange}
        onKeyDown={handleKeyDown}
      />
    </div>
  );
}

function Filter({ value, onChange }) {
  const handleClick = (key, e) => {
    e.preventDefault();
    onChange(key);
  };

  return (
    <div className="panel-tabs">
      <a
        href="#"
        onClick={handleClick.bind(null, 'ALL')}
        className={classNames({ 'is-active': value === 'ALL' })}
      >All</a>
      <a
        href="#"
        onClick={handleClick.bind(null, 'TODO')}
        className={classNames({ 'is-active': value === 'TODO' })}
      >ToDo</a>
      <a
        href="#"
        onClick={handleClick.bind(null, 'DONE')}
        className={classNames({ 'is-active': value === 'DONE' })}
      >Done</a>
    </div>
  );
}

function TodoItem({ item, onCheck }) {
  const handleChange = () => {
    onCheck(item);
  };

  return (
    <label className="panel-block">
      <input
        type="checkbox"
        checked={item.done}
        onChange={handleChange}
      />
      <span
        className={classNames({
          'has-text-grey-light': item.done
        })}
      >
        {item.text}
      </span>
    </label>
  );
}

function App() {
  return (
    <div className="container is-fluid">
      <Todo />
    </div>
  );
}

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

参考サイト

React入門チュートリアル (5) ToDoアプリを作ってみよう
CSSフレームワークのススメ - BULMAの導入と覚え書き
CSS フレームワーク Bulma チートシート
Reactのスタイリング(classNameやclassNamesの使い方)