TypeScript で React Hooks に入門するチュートリアル
はじめに
本投稿について
本投稿は先日Qiitaに投稿した記事をベースに加筆修正したものになります。
本投稿の背景と目的
React HooksはReactアプリケーションを開発する際のファーストチョイスになっていると言っても過言ではありません。
Reactの初学者がHooksを学ぶ際に、一通りの情報は公式ドキュメントにまとまっているのですが、従来の公式チュートリアルのHooks版があったらいいのにな〜、と思いました。
というわけで、React Hooksに入門するためのチュートリアルを提供することが本稿の目的です。
このチュートリアルでは、フックの中でも最も基本的かつ重要な useState
フックとuseEffect
フックをまずは習得します。この2つを覚えれば、ちょっとしたReactアプリケーションの開発を始めることが可能です。
追加したステップ
Zennに本投稿を移植以降、チュートリアルに以下の投稿を追加しました。
- ステップ5:useRefでDOM要素を参照して値を利用する
- ステップ6:書籍検索処理をカスタムフックで切り出す(2020-10-11追加)
今後も他のフックの利用方法を追記していく予定です。
対象とする読者
- React Hooks以前のReactコンポーネント開発の基礎は理解しており、これからReact Hooksを学びたい方
Hooksを使わない、従来のクラスコンポーネント・関数コンポーネントによる開発方法を復習したい方は、公式チュートリアルや、私が以前Qiitaに書いた記事などを参考にして頂ければと思います。
開発環境
本チュートリアルは、CodeSandboxの環境を使ってブラウザ上でステップ・バイ・ステップでアプリケーションを開発していく流れとなっています。
ブラウザ上ではなくローカル環境で進めたいという方は、 付録B ローカル開発環境のセットアップ を参考にして環境を構築ください。
動作確認済みの環境
アプリケーションの動作確認は、CodeSandboxの他、以下の環境で行いました。
- Mac OS X 10.15.6
- node v11.10.1
- npm 6.14.5
- yarn 1.19.1
- react 16.13.1
- typescript 3.7.2
これからつくるもの
このチュートリアルでは、これから読みたい・買いたい本をストックしておくちょっとしたWebアプリケーションを作成します。
書籍情報はGoogle Books APIsを利用してAjaxで取得します。
Zennに動画はアップロードできなかったので、動かしている様子は個人ブログへアップしました。
最終的な結果をここで確認することができます:最終結果
チュートリアルを進める前に実際に画面を操作して動作を把握しておくことをお勧めします。
ステップ1:書籍のリストを表示する
スターターコードを確認する
ブラウザからスターターコードを開いてください。以下のようにCodeSandboxの画面が表示されるはずです。
保存時に自動でフォークされて自分専用のサンドボックスとなりますので、このあと自由にファイルの追加や編集を行ってください。
左側のペインにはサンドボックスの情報やエクスプローラが表示されています。中央のペインでコード編集を行い、右側のペインでリアルタイムに表示を確認することができます。
スターターには以下のファイルを事前に準備済みです。
ファイル | 説明 |
---|---|
App.css | アプリケーション共通で利用するスタイルを定義(ReactにおけるCSSの管理方法は本稿範囲外なので、全てのスタイルをここで定義している) |
App.tsx | アプリケーションのメインReactコンポーネント |
BookDescription.ts | APIで取得する書籍情報の型を定義 |
BookToRead.ts | アプリケーションで保管する書籍情報の型を定義 |
index.css | index.tsxで利用するスタイルを定義 |
index.tsx | エントリポイントとなるReactコンポーネント |
package.json | npmの構成ファイル |
tsconfig.json | TypeScriptの構成ファイル |
上記のうち、App.tsx
以外のファイルはチュートリアルの中で変更することはありません。
ダミーデータをリスト表示する
App.tsx
には予めダミーの書籍データを用意してあるので、まずはこれをリスト形式で表示するコードを書きましょう。
App.tsx
const dummyBooks: BookToRead[] = [
{
id: 1,
title: "はじめてのReact",
authors: "ダミー",
memo: ""
},
{
id: 2,
title: "React Hooks入門",
authors: "ダミー",
memo: ""
},
{
id: 3,
title: "実践Reactアプリケーション開発",
authors: "ダミー",
memo: ""
}
];
CodeSandBoxのエクスプローラから、src
フォルダの下に新規ファイルBookRow.tsx
を作成してください。
まずはインポート。
BookRow.tsx
import React from "react";
import { BookToRead } from "./BookToRead";
次に、propsの型を定義します。
BookRow.tsx
type BookRowProps = {
book: BookToRead;
onMemoChange: (id: number, memo: string) => void;
onDelete: (id: number) => void;
};
BookToRead
型の書籍情報(book
)のほか、メモ項目の変更イベントのコールバック、書籍削除イベントのコールバックを持たせておきます。
そして、関数コンポーネントの本体を定義してエクスポートします。
BookRow.tsx
const BookRow = (props: BookRowProps) => {
const { title, authors, memo } = props.book;
const handleMemoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
props.onMemoChange(props.book.id, e.target.value);
};
const handleDeleteClick = () => {
props.onDelete(props.book.id);
};
return (
<div className="book-row">
<div title={title} className="title">
{title}
</div>
<div title={authors} className="authors">
{authors}
</div>
<input
type="text"
className="memo"
value={memo}
onChange={handleMemoChange}
/>
<div className="delete-row" onClick={handleDeleteClick}>
削除
</div>
</div>
);
};
export default BookRow;
このコンポーネントは表示とイベント伝搬を行うのみで状態管理は必要としないため、React Hooksの出番はなく、通常の関数コンポーネントとして実装できます。つまり、props
で受け取ったプロパティを用いてレンダリングを行い、子コンポーネントでonChange
やonClick
などのイベントが発生した際はprops
のプロパティを通じて親コンポーネントにイベントを伝搬します。
では次にApp.tsx
から今作ったコンポーネントを利用して表示を行いましょう。
インポート文を追加します。
App.tsx
import BookRow from "./BookRow";
ダミーデータを各要素をJSX要素に変換して変数に格納しましょう。今はまだBookRow
コンポーネントから発火されるイベントは無視します。
App.tsx
const App = () => {
const bookRows = dummyBooks.map((b) => {
return (
<BookRow
book={b}
key={b.id}
onMemoChange={(id, memo) => {}}
onDelete={(id) => {}}
/>
);
});
繰り返し出力するコンポーネントに対しては、key
属性を付ける必要があることを思い出してください。
次に、コンポーネントの戻り値となるJSX要素内に展開されるように記述します(クラス名がmain
のsection
要素配下)。
App.tsx
return (
<div className="App">
<section className="nav">
<h1>読みたい本リスト</h1>
<div className="button-like">本を追加</div>
</section>
<section className="main">{bookRows}</section>
</div>
);
これで、以下のようにリスト表示されるようになったでしょう。
これでステップ1は終了です。この時点でのコードはこのようになっているはずです。
ステップ2:書籍の削除とメモ書きを実装する
このステップの内容は、ステップ1終了時の状態から続けて実装します。
useStateフックによる状態管理
書籍の削除やメモ書きの変更のイベントを拾い、画面に反映させるためには、書籍のリストをコンポーネントのステート変数として管理する必要があります。
従来のReactでは、そのためにはクラスコンポーネントを作成して、this.state
の中に状態を管理する必要がありました。React Hooks
の導入以後は、関数コンポーネントにおいても状態管理の実現が可能となりました。
そのために用いるのがuseState
フックです。
実際にコードを書きながら、使い方を確認しましょう。
今回のステップでやりたいことは以下です。
-
dummyBooks
の内容を初期状態とするステート変数を作成する - 書籍削除のイベントを拾って上記ステート変数を更新する
- 同じくメモ書き変更のイベントを拾ってステート変数を更新する
順に実装していきましょう。
書籍のリストを状態管理する
まずはuseState
関数をインポートします。
App.tsx
import React, { useState } from "react";
このuseState
関数を利用して、ステート変数とその更新用関数を取得します。
App.tsx
const App = () => {
const [books, setBooks] = useState(dummyBooks);
useState
関数の引数には、そのステート変数の初期値を指定します。
上記のコード例だと、初めてApp
関数コンポーネントが呼び出された際(初回のレンダリング時)にbooks
変数に格納されているのはdummyBooks
で定義したダミーの書籍データの配列となります。
ステート変数books
の内容を表示するようにコードを修正します(dummyBooks
->books
)。ブラウザに表示される結果が変わらないことを確認してください。
App.tsx
const bookRows = books.map((b) => {
return (
<BookRow
book={b}
key={b.id}
onMemoChange={(id, memo) => {}}
onDelete={(id) => {}}
/>
);
});
削除イベントのハンドリング
削除イベントのハンドラ関数を定義しましょう。書籍のIDを受け取り、該当する書籍を配列から削除します。
クラスコンポーネントにおけるstate
の更新と同様、ステート変数の配列を直接操作するのではなく、新しい配列を生成して更新用関数に渡す点に注意してください。
App.tsx
const [books, setBooks] = useState(dummyBooks);
const handleBookDelete = (id: number) => {
const newBooks = books.filter((b) => b.id !== id);
setBooks(newBooks);
};
ここでは、filter
関数を使って、IDが一致する書籍を除外した配列を生成し、setBooks
関数を通じてステート変数の更新を行います。
JSXを修正し、BookRow
コンポーネントのonDelete
属性で先程のハンドラを呼び出すようにします。
App.tsx
const bookRows = books.map((b) => {
return (
<BookRow
book={b}
key={b.id}
onMemoChange={(id, memo) => {}}
onDelete={(id) => handleBookDelete(id)}
/>
);
});
実際の画面から任意の行の削除ボタンをクリックし、行が消えるようになったことを確認してください。
メモ書き変更イベントのハンドリング
同様のイベントのハンドラ関数を定義します。
App.tsx
const handleBookMemoChange = (id: number, memo: string) => {
const newBooks = books.map((b) => {
return b.id === id
? { ...b, memo: memo }
: b;
});
setBooks(newBooks);
}
books
に格納されている書籍データの配列のうち、IDが合致する要素はメモを更新した値を、それ以外の要素はそのままの値で新しい配列に格納します。
{ ...b, memo: memo }
上のコードは、b
の各プロパティを展開し、memo
プロパティだけを上書きした新しいオブジェクトを生成しています。
JSXを修正しイベントとハンドラの紐付けを行います。
App.tsx
const bookRows = books.map((b) => {
return (
<BookRow
book={b}
key={b.id}
onMemoChange={(id, memo) => handleBookMemoChange(id, memo)}
onDelete={(id) => handleBookDelete(id)}
/>
);
});
書籍リストのメモ欄が編集できるようになったことを画面で確認しましょう。
これでステップ2は終了です。この時点でのコードはこのようになっているはずです。
ステップ3:書籍を検索して追加する
このステップの内容は、ステップ2終了時の状態から続けて実装します。
検索ダイアログ
書籍の検索はモーダルダイアログで実現するため、react-modal
ライブラリを利用します。
CodeSandboxには既に依存関係を登録済みです。ローカル開発環境用のスタータープロジェクトにも登録済みですが、もしcreate-react-app
で一から環境を構築する場合は以下のようにライブラリを追加します。
$ yarn add react-modal
$ yarn add @types/react-modal
これから作成するのは以下のようなダイアログコンポーネントです。
各々の書籍情報をタイルっぽい形で表示するコンポーネントを先に作ります。
BookSearchItemコンポーネント
CodeSandboxのエクスプローラから、新しいファイルBookSearchItem.tsx
を作成してください。
まずはインポートとpropsの型定義。
BookSearchItem.tsx
import React from "react";
import { BookDescription } from "./BookDescription";
type BookSearchItemProps = {
description: BookDescription;
onBookAdd: (book: BookDescription) => void;
};
BookDescription
はAPIで取得した書籍情報のうち、タイトル、著者(群)、サムネイル画像(のURL)を保持する型です。それに加えて、サムネイル画像の右下にある「+」をクリックした際のイベントを拾うコールバック関数を含めたものが当コンポーネントのprops
となります。
続いてコンポーネント本体となる関数を定義し、エクスポートします。
BookSearchItem.tsx
const BookSearchItem = (props: BookSearchItemProps) => {
const { title, authors, thumbnail } = props.description;
const handleAddBookClick = () => {
props.onBookAdd(props.description);
};
return (
<div className="book-search-item">
<h2 title={title}>{title}</h2>
<div className="authors" title={authors}>
{authors}
</div>
{thumbnail ? <img src={thumbnail} alt="" /> : null}
<div className="add-book" onClick={handleAddBookClick}>
<span>+</span>
</div>
</div>
);
};
export default BookSearchItem;
特に難しいところはないかと思うので説明は割愛します。
BookSearchDialogコンポーネント(基本部分)
検索ダイアログの基本部分から作っていきましょう。エクスプローラから新規ファイルBookSearchDialog.tsx
を作成してください。
インポートとprops
の型定義です。コンポーネントのプロパティとしては、検索結果の表示最大件数(maxResults
)と書籍追加イベントを拾うコールバック関数(onBookAdd
)を持たせます。
BookSearchDialog.tsx
import React, { useState } from "react";
import { BookDescription } from "./BookDescription";
import BookSearchItem from "./BookSearchItem";
type BookSearchDialogProps = {
maxResults: number;
onBookAdd: (book: BookDescription) => void;
};
コンポーネント本体の関数を実装していきましょう。まずはuseState
関数を使ってステート変数を定義します。
BookSearchDialog.tsx
const BookSearchDialog = (props: BookSearchDialogProps) => {
const [books, setBooks] = useState([] as BookDescription[]);
const [title, setTitle] = useState("");
const [author, setAuthor] = useState("");
books
は書籍の検索結果を表す配列。初期値は空の配列です。
title
author
は検索条件のタイトルおよび著者名。どちらも初期値は空文字列です。
次にイベントハンドラのコールバック関数。
タイトル、著者名のinput
要素のonChange
イベントを拾い、それぞれのステート変数を更新します。
BookSearchDialog.tsx
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
};
const handleAuthorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setAuthor(e.target.value);
};
実際にこれらのイベントが発火してsetXXXの関数呼び出しを通じてステート変数が変化すると、Reactはそれを検知してコンポーネントの再レンダリングを行います。
再レンダリングを行うということは即ちBookSearchDialog
関数が呼び出されるということです。
例えばタイトルに「A」と入力した場合、setTitle(e.target.value)
によって、ステート変数の値がA
に更新されます。その後再レンダリングのためにBookSearchDialog
関数が呼び出されると(初回レンダリング時と同様に)以下のコード行が実行されますが、このときuseState
が返すtitle
の値はA
になっています。
const [title, setTitle] = useState("");
このあたりのステート変数の更新と再レンダリングの仕組みは押さえておく必要があります。(なお、親コンポーネントから渡されるpropsの値が変更された場合も、子コンポーネントは再レンダリングされます)
さらに、検索ボタンのクリックイベントをハンドリングするコールバックも定義しましょう。(実際の検索処理は後で実装します)
BookSearchDialog.tsx
const handleSearchClick = () => {
if (!title && !author) {
alert("条件を入力してください");
return;
}
// 検索実行
};
書籍追加イベントに対するコールバックも実装しておきます。これは子のBookSearchItem
で発火したイベントを親コンポーネントへ伝搬するだけです。
BookSearchDialog.tsx
const handleBookAdd = (book: BookDescription) => {
props.onBookAdd(book);
};
最後にレンダリング処理です。
検索結果はBookSearchItem
コンポーネントを配列の要素数だけ繰り返し出力します。各イベントとハンドラの紐付けを行いましょう。
BookSearchDialog.tsx
const bookItems = books.map((b, idx) => {
return (
<BookSearchItem
description={b}
onBookAdd={(b) => handleBookAdd(b)}
key={idx}
/>
);
});
return (
<div className="dialog">
<div className="operation">
<div className="conditions">
<input
type="text"
onChange={handleTitleInputChange}
placeholder="タイトルで検索"
/>
<input
type="text"
onChange={handleAuthorInputChange}
placeholder="著者名で検索"
/>
</div>
<div className="button-like" onClick={handleSearchClick}>
検索
</div>
</div>
<div className="search-results">{bookItems}</div>
</div>
);
エクスポートも忘れないように。
BookSearchDialog.tsx
export default BookSearchDialog;
ちょっと実装内容が多かったので、現時点のSearchBookDialog.tsx
のコード全量を載せておきます。
SearchBookDialog.tsx
(全量)
import React, { useState } from "react";
import { BookDescription } from "./BookDescription";
import BookSearchItem from "./BookSearchItem";
type BookSearchDialogProps = {
maxResults: number;
onBookAdd: (book: BookDescription) => void;
};
const BookSearchDialog = (props: BookSearchDialogProps) => {
const [books, setBooks] = useState([] as BookDescription[]);
const [title, setTitle] = useState("");
const [author, setAuthor] = useState("");
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
};
const handleAuthorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setAuthor(e.target.value);
};
const handleSearchClick = () => {
if (!title && !author) {
alert("条件を入力してください");
return;
}
// 検索実行
};
const handleBookAdd = (book: BookDescription) => {
props.onBookAdd(book);
};
const bookItems = books.map((b, idx) => {
return (
<BookSearchItem
description={b}
onBookAdd={(b) => handleBookAdd(b)}
key={idx}
/>
);
});
return (
<div className="dialog">
<div className="operation">
<div className="conditions">
<input
type="text"
onChange={handleTitleInputChange}
placeholder="タイトルで検索"
/>
<input
type="text"
onChange={handleAuthorInputChange}
placeholder="著者名で検索"
/>
</div>
<div className="button-like" onClick={handleSearchClick}>
検索
</div>
</div>
<div className="search-results">{bookItems}</div>
</div>
);
};
export default BookSearchDialog;
App.tsxからのダイアログ表示
次に、「本を追加」ボタンクリックで検索ダイアログをモーダル表示するようにApp.tsx
に手を加えます。
まずはインポートの追加と、react-modal
を利用するための準備を行います。
App.tsx
import Modal from "react-modal";
import BookSearchDialog from "./BookSearchDialog";
Modal.setAppElement("#root");
const customStyles = {
overlay: {
backgroundColor: "rgba(0, 0, 0, 0.8)"
},
content: {
top: "50%",
left: "50%",
right: "auto",
bottom: "auto",
marginRight: "-50%",
padding: 0,
transform: "translate(-50%, -50%)"
}
};
Modal.setAppElement
の呼び出しにより、モーダル表示時にオーバーレイで覆うDOM領域を指定します。
customStyles
はモーダルダイアログおよびオーバーレイの外観のスタイル設定となります。ここでは上記の通りコード入力してください。
次にステート変数を追加します。
App.tsx
const App = () => {
const [books, setBooks] = useState(dummyBooks);
const [modalIsOpen, setModalIsOpen] = useState(false);
「モーダルダイアログが開いているかどうか」という画面モードをステート変数として持たせて切り替えを行います。最初は閉じていてほしいので、初期値をfalse
にしています。
イベントハンドラを定義しましょう。
App.tsx
const handleAddClick = () => {
setModalIsOpen(true);
};
const handleModalClose = () => {
setModalIsOpen(false);
};
handleAddClick
は「本を追加」ボタンのクリックに対するものなので、モーダルダイアログを開くためにsetModalIsOpen
にtrue
を指定します。
handleModalClose
はモーダルダイアログが開かれた状態で、ダイアログの領域外をクリックした際に呼び出されるものです。(false
を指定します)
JSXにコードを追加します。
App.tsx
return (
<div className="App">
<section className="nav">
<h1>読みたい本リスト</h1>
<div className="button-like" onClick={handleAddClick}>
本を追加
</div>
</section>
<section className="main">{bookRows}</section>
<Modal
isOpen={modalIsOpen}
onRequestClose={handleModalClose}
style={customStyles}
>
<BookSearchDialog maxResults={20} onBookAdd={(b) => {}} />
</Modal>
</div>
);
修正箇所は以下の2つです。
- 「本を追加」の
div
にonClick
属性を付与し、handleAddClick
コールバック関数を紐付け -
Modal
コンポーネントを配置し、その子コンポーネントとして先ほど作成したBookSearchDialog
コンポーネントを指定
これで、検索ダイアログがモーダル表示できるようになったはずです。(検索処理は未実装なので動作しません)
ダイアログを閉じたい場合は、ダイアログの外をクリックしてください。
検索処理を実装する
検索ダイアログで入力した条件で、Google Books APIsを呼び出して結果を表示できるようにしましょう。
まずは、API呼び出しで利用する関数をBookSearchDialog.tsx
のインポート文の後ろあたりに作成します。実際のアプリケーションではAPI呼び出し処理は別ファイルにした方がよさそうですが、サンプルなのでBookSearchDialog.tsx
に入れることにします。
BookSearchDialog.tsx
function buildSearchUrl(title: string, author: string, maxResults: number): string {
let url = "https://www.googleapis.com/books/v1/volumes?q=";
const conditions: string[] = []
if (title) {
conditions.push(`intitle:${title}`);
}
if (author) {
conditions.push(`inauthor:${author}`);
}
return url + conditions.join('+') + `&maxResults=${maxResults}`;
}
function extractBooks(json: any): BookDescription[] {
const items: any[] = json.items;
return items.map((item: any) => {
const volumeInfo: any = item.volumeInfo;
return {
title: volumeInfo.title,
authors: volumeInfo.authors ? volumeInfo.authors.join(', ') : "",
thumbnail: volumeInfo.imageLinks ? volumeInfo.imageLinks.smallThumbnail : "",
}
});
}
buildSearchUrl
はAPIのURLを組み立てる関数、extractBooks
はAPIの呼び出し結果(JSON)からコンポーネントが欲しい形でデータを抽出する関数です。上記をコピー&ペーストしてください。
次にAPI呼び出しを実装していきます。
タイミングとしては検索ボタンクリックの際なので、以下のイベントハンドラの「検索実行」の箇所に書けばよいでしょうか?
BookSearchDialog.tsx
const handleSearchClick = () => {
if (!title && !author) {
alert("条件を入力してください");
return;
}
// 検索実行
};
React HooksにおいてはサーバとのAPI通信やLocalStorageへのアクセス等の、コンポーネント内に閉じない処理は副作用(あるいは作用)と呼ばれます。
副作用を実装する仕組みとして、useEffect
フックが提供されていますので、その方法をサンプルを通して確認していきましょう。
イベントハンドラ内ではAPIによる検索処理を行わず、モードを変更するのみにします。
BookSearchDialog.tsx
const handleSearchClick = () => {
if (!title && !author) {
alert("条件を入力してください");
return;
}
setIsSearching(true);
};
isSearching
は現在(のレンダリング処理時点で)検索処理実行中であることを表すboolean
型のステート変数で、もちろんuseState
関数を使って定義します。
BookSearchDialog.tsx
const [isSearching, setIsSearching] = useState(false);
useEffect
をインポートしましょう。
BookSearchDialog.tsx
import React, { useState, useEffect } from "react";
useState
を使ったステート変数定義の後ろに、useEffect
を使った副作用の実装を記述します。
BookSearchDialog.tsx
useEffect(() => {
if (isSearching) {
const url = buildSearchUrl(title, author, props.maxResults);
fetch(url)
.then((res) => {
return res.json();
})
.then((json) => {
return extractBooks(json);
})
.then((books) => {
setBooks(books);
})
.catch((err) => {
console.error(err);
});
}
setIsSearching(false);
}, [isSearching]);
useEffect
の第1引数には、副作用を記述した関数を渡します。
ここでは、isSearching
がtrue
の場合に以下の副作用を実行します。(isSearching
がfalse
の場合にもこの関数が呼び出されるので、条件判断は必須です)
-
fetch
関数によるAPIコール(Ajax) - 結果のJSONから書籍のデータを抽出
-
setBooks
関数によるステート変数books
の更新
useEffect
の第2引数には、副作用が依存するステート変数およびprops
のプロパティを列挙した配列を渡します。
Reactは、この配列に含まれるステート変数またはプロパティのいずれかの変更を検知した場合にのみ、副作用の関数呼び出しを行います。(省略した場合は、レンダリングの都度毎回呼び出されることになります)
今回の副作用のコードは、検索ボタンがクリックした時に実行されればよいので、isSearching
のみを配列に入れています。(title
author
props.maxResults
も参照してるぞとeslintに怒られますが、無視してください。気になる場合はこれらを追加しても動作に影響はないはずです)
これで、APIを使って検索を行い結果表示もできるようになったはずなので、動作確認をしてみてください。
書籍を選んでメイン画面のリストへ追加する
書籍の追加(「+」ボタンクリック)時のイベントはBookSearchItem
=>BookSearchDialog
=>App
と順に伝搬していくように実装済みなので、App
コンポーネントにイベントハンドラを実装します。
その前にApp
コンポーネントに置いてあったダミーの書籍情報は不要になったので削除し(const dummyBooks ...
を消す)、ステート変数の初期値も空配列にしておきましょう。
App.tsx
const [books, setBooks] = useState([] as BookToRead[]);
イベント引数でBookDescription
を受け取るのでインポート文を追加します。
App.tsx
import {BookDescription} from "./BookDescription";
イベントハンドラとなるコールバック関数は以下のようになります。
App.tsx
const handleBookAdd = (book: BookDescription) => {
const newBook: BookToRead = { ...book, id: Date.now(), memo: "" };
const newBooks = [...books, newBook];
setBooks(newBooks);
setModalIsOpen(false);
}
const newBooks = [...books, newBook]
で現在の書籍リストの末尾に、検索ダイアログで選択した書籍情報(から作ったBookToRead
オブジェクト)を追加し、setBooks
関数でステート変数を更新します。
また、追加後はモーダルダイアログを閉じるために、同様にsetModalIsOpen
関数でステート変数を更新します。
JSXを修正し、イベントとイベントハンドラを紐付けます。
App.tsx
<BookSearchDialog maxResults={20} onBookAdd={(b) => handleBookAdd(b)} />
長くなりましたが、これでステップ3は終了です。この時点でのコードはこのようになっているはずです。
ステップ4:書籍を検索して追加する
このステップの内容は、ステップ3終了時の状態から続けて実装します。
ここまででアプリケーションの動作としてはほぼ完成していますが、ブラウザをリロードしても書籍リストが消えてしまわないように、LocalStorageにデータを保存するように実装しましょう。
LocalStorageへのアクセスキーを定数定義します。
App.tsx
const APP_KEY = "react-hooks-tutorial"
const App = () => {
既に述べたように、LocalStorageの読み書きようなコンポーネント内で閉じない処理は副作用としてuseEffect
関数を用いて実装するのでした。
useEffect
のインポートを追加しておきましょう。
App.tsx
import React, { useState, useEffect } from "react";
まずは書き込み処理です。useState
によるステート変数取得処理の後ろあたりに以下のコードを記述します。
App.tsx
useEffect(() => {
localStorage.setItem(APP_KEY, JSON.stringify(books));
}, [books]);
useEffect
の第1引数に渡す関数には、books
配列を文字列化した値をLocalStorageに書き込む処理を記述します。
第2引数にはbooks
配列を指定することで、books
の内容が更新される都度、この副作用関数が実行されるようになります。
次は読み込み処理です。(注意)先ほどのuseEffectよりも前に記述してください
App.tsx
useEffect(() => {
const storedBooks = localStorage.getItem(APP_KEY);
if (storedBooks) {
setBooks(JSON.parse(storedBooks));
}
}, []);
この副作用は初回のレンダリング時に一度だけ実行すればよいので、このようなケースでは第2引数に空配列[]
をしてください。
書籍の追加やメモの更新を行った後、ブラウザをリロードして書籍リストが保持されていることを確認してください。
これでステップ4は終了です。この時点でのコードはこのようになっているはずです。
ステップ5:useRefでDOM要素を参照して値を利用する
このステップの内容は、ステップ4終了時の状態から続けて実装します。
書籍検索ダイアログ(BookSearchDialog.tsx
)では、APIの検索条件として使用するタイトル、著者名のテキストを取得するために、onChange
イベントを拾ってステート変数に格納していました。
BookSearchDialog.tsx
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
};
const handleAuthorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setAuthor(e.target.value);
};
しかし、これらの値は検索ボタンクリック時にしか使わないので、いちいちonChangeイベントを拾うのも無駄ですし、何より面倒くさいです。他に方法はないでしょうか。
このような場合、useRef
フックを利用して、DOM要素に対する参照を作成すると便利です。
まずはインポートしましょう。
BookSearchDialog.tsx
import React, { useState, useEffect, useRef } from "react";
title
author
のステート変数はいらなくなるので消して、代わりにuseRef
で参照を取得します。
BookSearchDialog.tsx
const BookSearchDialog = (props: BookSearchDialogProps) => {
const [books, setBooks] = useState([] as BookDescription[]);
//const [title, setTitle] = useState("");
//const [author, setAuthor] = useState("");
const titleRef = useRef<HTMLInputElement>(null);
const authorRef = useRef<HTMLInputElement>(null);
ステート変数の値を参照していた箇所は、参照オブジェクトのcurrent
プロパティ経由で取得します。ここではインプット要素を参照しているので、value
で値を取得します。
BookSearchDialog.tsx
useEffect(() => {
if (isSearching) {
const url = buildSearchUrl(titleRef.current!.value, authorRef.current!.value, props.maxResults);
fetch(url)
onChange
イベントのハンドラ関数も不要になったので削除し、handleSearchClick
内のステート変数使用箇所も修正します。
BookSearchDialog.tsx
// const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// setTitle(e.target.value);
// };
// const handleAuthorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// setAuthor(e.target.value);
// };
const handleSearchClick = () => {
if (!titleRef.current!.value && !authorRef.current!.value) {
alert("条件を入力してください");
return;
}
setIsSearching(true);
};
肝心なのはJSXの部分。タイトル、著者名のinput
のonChange
属性は不要なので削除して、代わりにref
属性を指定します。
これにより、DOM要素とuseRef
で取得したref
とが紐付けられます。
BookSearchDialog.tsx
return (
<div className="dialog">
<div className="operation">
<div className="conditions">
<input type="text" ref={titleRef} placeholder="タイトルで検索" />
<input type="text" ref={authorRef} placeholder="著者名で検索" />
</div>
<div className="button-like" onClick={handleSearchClick}>
検索
</div>
</div>
<div className="search-results">{bookItems}</div>
</div>
);
ここまで実装したら画面を動かしてみて、書籍検索ダイアログがこれまでどおり動作すればOKです。
なお、公式ドキュメントにあるとおり、ref
はDOM要素への参照を保持するだけでなく、あらゆる書き換え可能な値を保持しておくのに使えます。また、その中身が変更になってもそのことを通知しないので、コンポーネントの再レンダリングは発生しません。
そのような使用例も今後このチュートリアルに追加できればと思います。
これでステップ5は終了です。この時点でのコードはこのようになっているはずです。
ステップ6:書籍検索処理をカスタムフックで切り出す
このステップの内容は、ステップ5終了時の状態から続けて実装します。
Google Books APIsを用いた書籍検索処理は BookSearchDialog
の中に書いていましたが、単一責任の原則(SRP)に則り、別の場所へ切り出したいと思います。このような場合、カスタムフック(独自フック)を使うとよいです。
カスタムフックの作成
srcフォルダの下に useBookData.ts
を作成してください。カスタムフックの名称は"use"で始まるようにします。
useBookData.ts
export const useBookData = () => {
}
useBookData.ts
に、 BookSearchDialog
にある書籍検索に関するコードを移植します。まずはAPI呼び出し、データ解析用のヘルパ関数を移動します。
useBookData.ts
import { BookDescription } from "./BookDescription";
function buildSearchUrl(
title: string,
author: string,
maxResults: number
): string {
let url = "https://www.googleapis.com/books/v1/volumes?q=";
const conditions: string[] = [];
if (title) {
conditions.push(`intitle:${title}`);
}
if (author) {
conditions.push(`inauthor:${author}`);
}
return url + conditions.join("+") + `&maxResults=${maxResults}`;
}
function extractBooks(json: any): BookDescription[] {
const items: any[] = json.items;
return items.map((item: any) => {
const volumeInfo: any = item.volumeInfo;
return {
title: volumeInfo.title,
authors: volumeInfo.authors ? volumeInfo.authors.join(", ") : "",
thumbnail: volumeInfo.imageLinks
? volumeInfo.imageLinks.smallThumbnail
: ""
};
});
}
export const useBookData = () => {
}
次に useEffect
および依存するステート変数を移動します。import { useState, useEffect} from "react";
のインポート文も追加します。
useBookData.ts
export const useBookData = () => {
const [books, setBooks] = useState([] as BookDescription[]);
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
if (isSearching) {
const url = buildSearchUrl(
titleRef.current!.value,
authorRef.current!.value,
props.maxResults
);
fetch(url)
.then((res) => {
return res.json();
})
.then((json) => {
return extractBooks(json);
})
.then((books) => {
setBooks(books);
})
.catch((err) => {
console.error(err);
});
}
setIsSearching(false);
}, [isSearching]);
}
上記のように、カスタムフックの関数内では、 useEffect
や useState
をそのまま利用することができます。
titleRef
authorRef
props.masResults
はこの関数からは見えないので、引数で必要な値を渡すようにします。そして、 books
setIsSearching
は BookSearchDialog
コンポーネント側で必要とするため、戻り値として返却するようにします。
useBookData.ts
export const useBookData = (title: string, author: string, maxResults: number) => {
const [books, setBooks] = useState([] as BookDescription[]);
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
if (isSearching) {
const url = buildSearchUrl(title, author, maxResults);
fetch(url)
.then((res) => {
return res.json();
})
.then((json) => {
return extractBooks(json);
})
.then((books) => {
setBooks(books);
})
.catch((err) => {
console.error(err);
});
}
setIsSearching(false);
}, [isSearching]);
return [books, setIsSearching] as const;
}
※TypeScriptの場合、複数の型の値を配列で返却して呼び出し元でデストラクチャリングで受け取るには、上記のように as const
を付けないとコンパイルエラーとなるので注意してください。
では次に BookSearchDialog
コンポーネントでこのカスタムフックを利用するようにしましょう。まずは useBookData
をインポートします。
BookSearchDialog.tsx
import React, { useState, useRef } from "react";
import { BookDescription } from './BookDescription'
import BookSearchItem from "./BookSearchItem";
import { useBookData } from "./useBookData";
useBookData
を呼び出し、戻り値から books
setIsSearching
をデストラクチャリングで代入します。
BookSearchDialog.tsx
const BookSearchDialog = (props: BookSearchDialogProps) => {
const titleRef = useRef<HTMLInputElement>(null);
const authorRef = useRef<HTMLInputElement>(null);
const [books, setIsSearching] = useBookData(
titleRef.current ? titleRef.current!.value : "",
authorRef.current ? authorRef.current!.value : "",
props.maxResults
);
ここまで実装したら、書籍の検索ダイアログが今までどおりに動作することを確認しましょう。
カスタムフックを使うことで、「書籍の検索」というロジックを、「検索ダイアログの表示」というプレゼンテーションから分離することができ、プログラムの見通しがよくなりました。
リファクタリング
現在の実装には、一点だけ少し気になる部分があります。
useBookData
関数の定義の冒頭部分の isSearching
というステート変数ですが、この状態は検索ダイアログコンポーネントの状態であり、書籍検索ロジックとは本来無関係なはずです。
useBookData.ts
export const useBookData = (title: string, author: string, maxResults: number) => {
const [books, setBooks] = useState([] as BookDescription[]);
const [isSearching, setIsSearching] = useState(false);
このステート変数は消してしまいましょう。
useBookData.ts
export const useBookData = (title: string, author: string, maxResults: number) => {
const [books, setBooks] = useState([] as BookDescription[]);
useEffect(() => {
if (title || author) {
const url = buildSearchUrl(title, author, maxResults);
fetch(url)
.then((res) => {
return res.json();
})
.then((json) => {
return extractBooks(json);
})
.then((books) => {
setBooks(books);
})
.catch((err) => {
console.error(err);
});
}
}, [title, author, maxResults]);
return books;
}
useEffect
内の条件は「 title
または author
が空でないこと」とし、第2引数の依存性配列も isSearchg
から title, author, maxResults
に変更します。
戻り値は books
のみとなります。
BookSearchDialog
は以下のように修正します。
BookSearchDialog.tsx
const BookSearchDialog = (props: BookSearchDialogProps) => {
const titleRef = useRef<HTMLInputElement>(null);
const authorRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState("");
const [author, setAuthor] = useState("");
const books = useBookData(title, author, props.maxResults);
const handleSearchClick = () => {
if (!titleRef.current!.value && !authorRef.current!.value) {
alert("条件を入力してください");
return;
}
setTitle(titleRef.current!.value);
setAuthor(authorRef.current!.value);
};
BookSearchDialog
からも isSearching
ステート変数は削除し、検索ボタンクリックのハンドラ関数で、 title
author
のステート変数に値をセットするようにしました。
検索ボタンがクリックされ title
または author
の値が変わると、Reactによって BookSearchDialog
コンポーネントの再レンダリングが実行されます。すると useBookData
関数が呼び出されますが、 useEffect
の第2引数を以下のように指定しているため、 title
または author
の値が前回と変わった時のみ、第1引数の関数が実行されます。
}, [title, author, maxResults]);
また、その第1引数の関数では冒頭のガード条件が効きますので、 title
または author
に値が入っている時だけ、fetch処理が行われることになります。
useEffect(() => {
if (title || author) {
const url = buildSearchUrl(title, author, maxResults);
fetch(url)
このような仕組みによって、fetch処理は検索ダイアログの初回レンダリング時には実行されませんし、同じ検索文字列で連続して検索ボタンを押しても1度しか実行されません。
興味がある方は useEffect
内に console.log
でデバッグログを入れて確認してみてください。
これでチュートリアルのすべてのステップが完了しました。お疲れさまでした!
最終結果はこちらです。
付録
付録A ソースコード一式
GitHubに上げています。
付録B ローカル開発環境のセットアップ
スターター用のプロジェクトをGitHubに用意しましたので使ってください。
前提条件:以下がインストール済みであること
- node
- npm
- (yarn)
- git
$ git clone https://github.com/yonetty/react-hooks-tutorial-starter.git
$ cd react-hooks-tutorial-starter
$ yarn install
$ yarn start
もちろんnpmでやってもらっても構いません。(npm install
npm start
に読み替えてください)
ブラウザでスターターの画面が表示されればOKです。
Discussion
試しにgit cloneした状態で動かしてみたのですが
このロジックだと
2件登録→1件目削除→再度追加
のようなオペレーションでbookRowのkeyに重複が発生して重複したデータに対する操作がおかしくなります。
取り急ぎ下記ロジックで簡易対応はできました(numberのオーバフローは未考慮)。
※ JSもReactも触り始めたばかりでもっといい方法や文法があると思います。
本当はAPIから取得できるidを使うのが良さそうですが今の仕様が同じ本を追加可能となっているので上記対応としています。
ご指摘ありがとうございます。たしかにその操作でキー重複発生してしまいますね。
同じ本を追加しても意味はないので、キーを返ってくるIDかISBNにして、重複チェックをする仕様にするのがよさそうですね。フィードバック感謝です。
とりあえずタイムスタンプを使って回避するように修正しました。
id: Date.now()
あーなるほど
重複避けたいだけならその方が簡易に対応できますね。