2020.05.06

React入門チュートリアル (5) ToDoアプリを作ってみよう


この連載記事は、これから React を学びたい JavaScript 開発者のための入門コンテンツです。対象とする React のバージョンは執筆時点で最新の v16.13 です。連載記事は以下の通り。

1️⃣ Reactとは何か
2️⃣ JSX
3️⃣ 属性と状態
4️⃣ フォームとイベントハンドリング
5️⃣ ToDoアプリを作ってみよう
6️⃣ 副作用
7️⃣ カスタムフック
8️⃣ Reactプロジェクトを始める方法

本章はここまでのおさらいです。ToDo アプリのサンプルを一緒に作っていきましょう。

作るもの

See the Pen React Todo Demo by Masahiro Harada (@MasahiroHarada) on CodePen.

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

実際に触って機能や挙動を確かめておいてください。

コンポーネント設計

実装を始める前に、ある程度どう作るかの検討をつけます。考えるのは以下の2点です。

  • どのようにコンポーネントを分けるか。
  • それぞれのコンポーネントはどのような属性(props)を受け取って、どのような状態(state)を管理するか。

今回は、以下のようにコンポーネントを分割したいと思います。

Todo コンポーネントは、アプリ全体です。

これがアプリの大元になるので、外部からの props は不要です。

state に関しては、まずタスクのリストが必要でしょう。また、何を見せるかを決める役割があるため、選択されているフィルタリング条件(ALL or ToDo or Done)を管理する必要がありそうです。

Input コンポーネントは、タスクの入力欄です。

入力欄なので、入力値を state として管理します。この入力値は、エンターキーが押されるまで親(Todo)が知る必要はないので、このコンポーネント内で管理します。

エンターキーが押されたら、親のタスクリストを更新する必要があります。このパターンは、前回説明しましたね?親から子にエンターキーを押したときのイベントハンドラ関数を渡すとよいでしょう。

Filter コンポーネントは、 タブのフィルタリング部分です。

これも Input と同様、それぞれのタブが押されたときのイベントハンドラ関数を props として受け取る必要がありそうです。

そのほか、選択中のタブは見た目を変える必要があります。ということは、Filter はいまどのタブが選ばれているか、知っていなければいけません。これは props と state のどちらにするべきでしょうか?

親の Todo に、フィルタリング条件を state 管理させようとしていることを考えると、親から props で渡してもらうのがよいと思います。さもなければ二重管理になってしまいますから。

TodoItem コンポーネントは、タスク一個分を表します。

当然、一個分のタスク情報を親から props で渡してもらう必要があります。

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

言い換えるなら、TodoItem は、どのタスクがチェックされたか、親である Todo 知らせる必要があります。「知らせる」とはまさに「イベント」のことですよね。

一般化すると、フォームやボタン、リンク、タブなどインタラクティブな UI 要素があるということは、ハンドラが必要です。誰がハンドラを定義して、誰が実行するかを考えましょう。

ざっとこんな感じで開発に進めると思います。実際の開発でも、このようにアタリをつけてからコードを書き始めるとスムーズです。そこまで複雑な、または多様な実装パターンがあるわけでもないので、落ち着いて、一つ一つの機能やデータを解きほぐしていけば大丈夫です。

実装!

はじめに

まず、以下の 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/babel">
    // ここにコードを書いていく
  </script>
</body>
</html>

CSS フレームワーク Bulma を読み込んでいます。また、画面の上部に余白を足すために <style> 要素を記述しています。行儀が悪いですが、デモ用ということでお許しください。

JavaScript は今までの例と同様、React、ReactDOM、babel を読み込んでいます。さらに、React の className をより扱いやすくする classnames というライブラリも使用します。

以降で記述するコードは最後の <script> 要素の中に書かれると思ってください。

さて、以下のコードからスタートしましょう。

function Todo() {
  return null;
}

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

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

レンダリングされるルートコンポーネントとして App を作成します。Todo の左右に余白を持つ目的もあります。

タスクを表示する

ここから、一つずつ機能を作っています。タスクを表示する機能からです。

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 コードを追加します。

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 は以下のようになります。

<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>

ループで生成される要素には key が必要だったことを思い出しましょう。タスクに用意した key プロパティを利用します。

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

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

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);
};

実装したハンドラを TodoItemonCheck props に指定します。

{items.map(item => (
  <TodoItem
    key={item.key}
    item={item}
    onCheck={handleCheck}
  />
))}

チェックの ON / OFF はできているはずですが、さらに、完了済みのタスクは文字色を灰色に変化されます。これには Bulma のヘルパークラスを用います。

TodoItem の JSX を以下の通り編集します。

<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 クラスを適用させるために <span> で囲ったうえで、classnames ライブラリを利用しています。以下は…

className={classNames({
  'has-text-grey-light': item.done // 真偽値
})}

これと同じ意味です。

className={item.done ? 'has-text-grey-light' : ''}

classnames には他にもたくさん便利な書き方がありますし、React 開発ではスタンダードなので、GitHub のページを見てみてください。

ここまでで TodoItem コンポーネントは完成です。チェックの切り替えと、ON 時の文字色の変化を確認しておきましょう。

タスクを作成する

次はタスクの作成機能です。

まずは入力欄の値を管理するだけの 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 に、追加処理を行うハンドラ関数を実装します。テキストは子から渡されるので、keydone を追加して、タスクリストに追加します。

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

準備が整ったので、.panel-heading の下に Input を配置します。

Todo
<Input onAdd={handleAdd} />

ここでは子→親の順で機能を作りましたが、親のハンドラから、何を渡されて何をしたらいいか、考えながら作り始めてもよいでしょう。実際は交互に同時に書き進める感じです。

フィルタリング

最後にフィルタリング機能です。

まず Todo に state を追加します。フィルタリング条件は、ALL / TODO / DONE の文字列で表現することにします。

Todo
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>
  );
}

この部分の記述がポイントです。

<a onClick={handleClick.bind(null, 'ALL')}>

ハンドラに引数を渡すための、bind を使ったテクニックです。

// イベントハンドラに引数を渡したいときどうするか?
<Comp onSomething={doSomething} />

// ❌ この書き方だと、関数を渡すのではなく実行してしまう
<Comp onSomething={doSomething(123)} />

// ✅ アロー関数でも OK
<Comp onSomething={() => doSomething(123)} />

// ✅ bind を使う
<Comp onSomething={doSomething.bind(null, 123)} />

さて、Todo に戻って、フィルタリング条件を更新する関数を作成します。

Todo
const handleFilterChange = value => setFilter(value);

ここまででフィルタリング条件の切り替えはできていますが、大きな課題が残っています。条件にもとづいて、実際にどうやって絞り込みを行えばよいでしょうか?

items を直接表示するのではなく、条件に応じてフィルタリングされた結果を表示します。以下のコードを Todo に追加してください。

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

タスク表示箇所と件数表示箇所を displayItems を使うように編集します。

Todo
{displayItems.map(item => (
  // 中略
))}
<div className="panel-block">
  {displayItems.length} items
</div>

フィルタリング機能としては出来ているはずです。タブの表示切り替えを実装して完成です。ここでも classnames を使用します。

Filter
<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>

これで終わりです 🥳

完成コード

冒頭に載せた CodePen でも見られますが、こちらにも完成コードを掲載します。

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);

ここまで書いて、Todo にフィルタリングのロジックがあるのが少し気になりました。

TodoTodoItem の間に、タスクのリスト(および件数表示)を表示する TodoList コンポーネントを作成して、フィルタリングのロジックはそちらに移せば、もっとスッキリするかもしれません。

このリファクタリングも、試してみるといい練習になりそうです。

本章では、2 〜 4章で説明した JSX、props、state の知識を組み合わせて、ミニアプリを作成してみました。実際に開発するイメージを掴んでいただけていれば幸いです。

連載

  1. Reactとは何か
  2. JSX
  3. 属性と状態
  4. フォームとイベントハンドリング
  5. ToDoアプリを作ってみよう
  6. 副作用
  7. カスタムフック
  8. Reactプロジェクトを始める方法