✏️

TypeScript で React Hooks に入門するチュートリアル

2020/09/25に公開
4

はじめに

本投稿について

本投稿は先日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で取得します。

react_00.png

Zennに動画はアップロードできなかったので、動かしている様子は個人ブログへアップしました。

最終的な結果をここで確認することができます:最終結果
チュートリアルを進める前に実際に画面を操作して動作を把握しておくことをお勧めします。

ステップ1:書籍のリストを表示する

スターターコードを確認する

ブラウザからスターターコードを開いてください。以下のようにCodeSandboxの画面が表示されるはずです。
保存時に自動でフォークされて自分専用のサンドボックスとなりますので、このあと自由にファイルの追加や編集を行ってください。

react_01.png

左側のペインにはサンドボックスの情報やエクスプローラが表示されています。中央のペインでコード編集を行い、右側のペインでリアルタイムに表示を確認することができます。
スターターには以下のファイルを事前に準備済みです。

ファイル 説明
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で受け取ったプロパティを用いてレンダリングを行い、子コンポーネントでonChangeonClickなどのイベントが発生した際は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要素内に展開されるように記述します(クラス名がmainsection要素配下)。

App.tsx

  return (
    <div className="App">
      <section className="nav">
        <h1>読みたい本リスト</h1>
        <div className="button-like">本を追加</div>
      </section>
      <section className="main">{bookRows}</section>
    </div>
  );

これで、以下のようにリスト表示されるようになったでしょう。

react_02.png

これでステップ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

これから作成するのは以下のようなダイアログコンポーネントです。

react_03.png

各々の書籍情報をタイルっぽい形で表示するコンポーネントを先に作ります。

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は「本を追加」ボタンのクリックに対するものなので、モーダルダイアログを開くためにsetModalIsOpentrueを指定します。
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つです。

  • 「本を追加」のdivonClick属性を付与し、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引数には、副作用を記述した関数を渡します。
ここでは、isSearchingtrueの場合に以下の副作用を実行します。(isSearchingfalseの場合にもこの関数が呼び出されるので、条件判断は必須です)

  • 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の部分。タイトル、著者名のinputonChange属性は不要なので削除して、代わりに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]);
}

上記のように、カスタムフックの関数内では、 useEffectuseState をそのまま利用することができます。

titleRef authorRef props.masResults はこの関数からは見えないので、引数で必要な値を渡すようにします。そして、 books setIsSearchingBookSearchDialog コンポーネント側で必要とするため、戻り値として返却するようにします。

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です。

react_04.png

Discussion

手酢戸太郎手酢戸太郎

試しにgit cloneした状態で動かしてみたのですが

const newBook: BookToRead = { ...book, id: books.length + 1, memo: "" };

このロジックだと
2件登録→1件目削除→再度追加
のようなオペレーションでbookRowのkeyに重複が発生して重複したデータに対する操作がおかしくなります。
取り急ぎ下記ロジックで簡易対応はできました(numberのオーバフローは未考慮)。
※ JSもReactも触り始めたばかりでもっといい方法や文法があると思います。

    const index = books.length === 0 ? 1 : books.reduce( ( a, b ) => ( a.id > b.id ? a : b ) ).id + 1;
    const newBook: BookToRead = { ...book, id: index, memo: "" };

本当はAPIから取得できるidを使うのが良さそうですが今の仕様が同じ本を追加可能となっているので上記対応としています。

Takeshi YonekuboTakeshi Yonekubo

ご指摘ありがとうございます。たしかにその操作でキー重複発生してしまいますね。
同じ本を追加しても意味はないので、キーを返ってくるIDかISBNにして、重複チェックをする仕様にするのがよさそうですね。フィードバック感謝です。

Takeshi YonekuboTakeshi Yonekubo

とりあえずタイムスタンプを使って回避するように修正しました。
id: Date.now()