この連載記事は、これから React を学びたい JavaScript 開発者のための入門コンテンツです。対象とする React のバージョンは執筆時点で最新の v16.13 です。連載記事は以下の通り。
Reactとは何か
JSX
属性と状態
フォームとイベントハンドリング
ToDoアプリを作ってみよう
副作用
カスタムフック
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);
};
実装したハンドラを TodoItem
の onCheck
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
に、追加処理を行うハンドラ関数を実装します。テキストは子から渡されるので、key
と done
を追加して、タスクリストに追加します。
const handleAdd = text => {
setItems([...items, { key: getKey(), text, done: false }]);
};
準備が整ったので、.panel-heading
の下に Input
を配置します。
<Input onAdd={handleAdd} />
ここでは子→親の順で機能を作りましたが、親のハンドラから、何を渡されて何をしたらいいか、考えながら作り始めてもよいでしょう。実際は交互に同時に書き進める感じです。
フィルタリング
最後にフィルタリング機能です。
まず 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>
);
}
この部分の記述がポイントです。
<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
に戻って、フィルタリング条件を更新する関数を作成します。
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>
これで終わりです
完成コード
冒頭に載せた 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
にフィルタリングのロジックがあるのが少し気になりました。
Todo
と TodoItem
の間に、タスクのリスト(および件数表示)を表示する TodoList
コンポーネントを作成して、フィルタリングのロジックはそちらに移せば、もっとスッキリするかもしれません。
このリファクタリングも、試してみるといい練習になりそうです。
本章では、2 〜 4章で説明した JSX、props、state の知識を組み合わせて、ミニアプリを作成してみました。実際に開発するイメージを掴んでいただけていれば幸いです。