React公式ドキュメントを読む27 Reactでアプリ作成を進める手順

前回からの続きですが、このページだけ読んでも分かる内容になっています。 今回読むReact公式ドキュメントは「React の流儀」のページです。

Reactでウェブアプリを作成する場合、どのような手順で進めていくのでしょうか。実際に簡単なサンプルを作成しながら制作手順を確認しましょう。

画面デザインとデータを用意する

まず、最終的に作りたい画面デザインを用意します。Photoshopのようなグラフィックソフトで作成しても良いでしょうし、手書きでも良いでしょう。今回は、以下のような画面のウェブアプリを作成します。

これから作成するウェブアプリは、検索機能付きの商品一覧ページです。商品カテゴリはスポーツ用品や電化製品です。これらの商品データをJSON形式にしたものが以下です。

[
  {category: "Sporting Goods", price: "$49.99", stocked: true, name: "Football"},
  {category: "Sporting Goods", price: "$9.99", stocked: true, name: "Baseball"},
  {category: "Sporting Goods", price: "$29.99", stocked: false, name: "Basketball"},
  {category: "Electronics", price: "$99.99", stocked: true, name: "iPod Touch"},
  {category: "Electronics", price: "$399.99", stocked: false, name: "iPhone 5"},
  {category: "Electronics", price: "$199.99", stocked: true, name: "Nexus 7"}
];

1. 画面を機能単位でコンポーネントの階層構造に分ける

画面デザインとデータが用意できたら、次に画面を機能単位でコンポーネントの階層構造に分けます。この際、単一責任の原則を意識します。ひとつのコンポーネントはひとつの機能だけを担うということです。今回作成するサンプルでは、以下のように5つのコンポーネントに分けることにします。

  • 橙色 … FilterableProductTable 全体
  • 青色 … SearchBar       検索
  • 緑色 … ProductTable     商品テーブル
  • 水色 … ProductCategoryRow 商品カテゴリの行
  • 赤色 … ProductRow      商品の行

5つのコンポーネントに名前を付けたら、階層構造にして並べます。

  • FilterableProductTable
    • SearchBar
    • ProductTable
      • ProductCategoryRow
      • ProductRow

今回は上記のように分けましたが、この分け方だけが正解ではありません。例えば、将来的に商品の表示順を並べ替えるソート機能を追加するなら、表のヘッダーを別コンポーネントに分けておくほうが良いかもしれません。

2. Reactで静的バージョンを作成する

コンポーネントの階層構造が決まったら、Reactで静的バージョンを作成します。 静的バージョンとは、UIは描画されるけれどユーザーは操作できないバージョンのことです。

静的バージョンでは、stateは使いません。データの受け渡しはpropsを通じて行います。ユーザーがUIを操作できるバージョンでは state が必要になりますが、静的バージョンを作成する時点では state は使いません。

以下のコードは、とりあえずUIを描画するだけの静的バージョンです。

// 商品カテゴリの行
class ProductCategoryRow extends React.Component {
  render() {
    const category = this.props.category;
    return (
      <tr>
        <th colSpan="2">
          {category}
        </th>
      </tr>
    );
  }
}

// 商品の行
class ProductRow extends React.Component {
  render() {
    const product = this.props.product;
    const name = product.stocked ?
      product.name :
      <span style={{color: 'red'}}>
        {product.name}
      </span>;

    return (
      <tr>
        <td>{name}</td>
        <td>{product.price}</td>
      </tr>
    );
  }
}

// 商品テーブル
class ProductTable extends React.Component {
  render() {
    const rows = [];
    let lastCategory = null;
    
    this.props.products.forEach((product) => {
      if (product.category !== lastCategory) {
        rows.push(
          <ProductCategoryRow
            category={product.category}
            key={product.category} />
        );
      }
      rows.push(
        <ProductRow
          product={product}
          key={product.name} />
      );
      lastCategory = product.category;
    });

    return (
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Price</th>
          </tr>
        </thead>
        <tbody>{rows}</tbody>
      </table>
    );
  }
}

// 検索
class SearchBar extends React.Component {
  render() {
    return (
      <form>
        <input type="text" placeholder="Search..." />
        <p>
          <input type="checkbox" />
          {' '}
          Only show products in stock
        </p>
      </form>
    );
  }
}

// 全体
class FilterableProductTable extends React.Component {
  render() {
    return (
      <div>
        <SearchBar />
        <ProductTable products={this.props.products} />
      </div>
    );
  }
}

// 商品データ
const PRODUCTS = [
  {category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
  {category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
  {category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
  {category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
  {category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
  {category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];

//レンダリング
ReactDOM.render(
  <FilterableProductTable products={PRODUCTS} />,
  document.getElementById('container')
);

上記のサンプルソースをHTMLファイルにしたものが以下です。

https://programming-world.net/sample/react_doc/howto1-1.html

上記コードは、コンポーネントの階層構造に応じて、まず FilterableProductTable を呼び出し、次にFilterableProductTable からSearchBar と ProductTable を呼び出し、次にProductTable からProductCategoryRowとProductRowを呼び出すプログラムとなっています。

商品データは、変数PRODUCTSに代入して、コンポーネントにproductsという名前の props として渡しています。コンポーネント側ではthis.props.productsとしてデータを利用します。

静的バージョンでは、コンポーネントはUIを描画するだけなので、コンポーネントのなかにあるのはrender()メソッドだけです。また、静的バージョンでは state は使わず、コンポーネント間のデータの受け渡しはpropsで行います。

3. どのデータを state にするべきか?

UIを操作可能なものにするために、どのデータを state にするべきかを検討します。stateの導入は必要最小限にします。もし props で機能を果たしているなら、そのデータをわざわざ state にする必要はありません。

propsとstateの違いですが、propsがコンポーネントへ渡されるデータであるのに対して、stateはコンポーネントの内部で制御されるデータです。コンポーネント内にstateを導入することで、ユーザー操作による変化を管理してUIの状態を制御できます。

では、今回のサンプル作成でどのデータを state にするべきか検討していきましょう。今回のサンプルには、以下のデータが登場します。

  • 元となる商品のリスト
  • ユーザが入力した検索文字列
  • チェックボックスの値
  • フィルタ済みの商品のリスト

上記のうち、どれが state になるべきでしょうか。その判断には以下の3つのポイントを確認してみることです。

  1. 親から props として渡されたデータなら state ではありません
  2. 時間が経過しても変化しないデータなら state ではありません
  3. 他の props や state を使って算出可能なデータなら state ではありません

上記の3つのポイントを、先ほどのデータに当てはめてみます。

  • 元となる商品のリスト → props で渡されるので state ではない
  • ユーザが入力した検索文字列 → 時間経過で変化する可能性があり、算出できないデータなので state である
  • チェックボックスの値 → 時間経過で変化する可能性があり、算出できないデータなので state である
  • フィルタ済みの商品のリスト → 商品リストとチェックボックスの値から算出できるので state ではない

検証の結果、stateにするべきデータは「ユーザが入力した検索文字列」、および、「チェックボックスの値」の2つに決まりました。

4. どこに state を配置するべきか?

どのデータをstateにするべきかは決まりました。次は、どのコンポーネントのなかに state を配置するべきかを検討します。どこに state を配置するべきかを判断する際には、以下のポイントを確認すると良いでしょう。

  • その state を必要とするすべてのコンポーネントを確認する
  • それらの共通の親コンポーネントを見つける
  • 共通の親コンポーネントか、その階層構造でさらに上位の別のコンポーネントに state を配置するべきである
  • state を配置するべきコンポーネントが見つからない場合には、state を保持するためだけの新しいコンポーネントを作って、共通の親コンポーネントの上に配置する

上記のポイントを、今回のサンプル作成に当てはめると、SearchBar では検索文字列とチェック状態を表示する必要があります。また、ProductTable では商品リストをフィルタする必要がありそうです。

ここで、コンポーネントの階層構造を思い出してみましょう。

  • FilterableProductTable
    • SearchBar
    • ProductTable
      • ProductCategoryRow
      • ProductRow

階層構造を確認してみると、SearchBar と ProductTable の共通の親コンポーネントは FilterableProductTable です。検討の結果、 FilterableProductTable コンポーネント内にstateを配置するべきという結論になりました。

以下のコードは、 FilterableProductTable コンポーネント内にstateを配置して、このサンプルアプリが動作するようにしたものです。

// 商品カテゴリの行
class ProductCategoryRow extends React.Component {
  render() {
    const category = this.props.category;
    return (
      <tr>
        <th colSpan="2">
          {category}
        </th>
      </tr>
    );
  }
}

// 商品の行
class ProductRow extends React.Component {
  render() {
    const product = this.props.product;
    const name = product.stocked ?
      product.name :
      <span style={{color: 'red'}}>
        {product.name}
      </span>;

    return (
      <tr>
        <td>{name}</td>
        <td>{product.price}</td>
      </tr>
    );
  }
}

// 商品テーブル
class ProductTable extends React.Component {
  render() {
    const filterText = this.props.filterText;
    const inStockOnly = this.props.inStockOnly;

    const rows = [];
    let lastCategory = null;

    this.props.products.forEach((product) => {
      if (product.name.indexOf(filterText) === -1) {
        return;
      }
      if (inStockOnly && !product.stocked) {
        return;
      }
      if (product.category !== lastCategory) {
        rows.push(
          <ProductCategoryRow
            category={product.category}
            key={product.category} />
        );
      }
      rows.push(
        <ProductRow
          product={product}
          key={product.name}
        />
      );
      lastCategory = product.category;
    });

    return (
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Price</th>
          </tr>
        </thead>
        <tbody>{rows}</tbody>
      </table>
    );
  }
}

// 検索
class SearchBar extends React.Component {
  constructor(props) {
    super(props);
    this.handleFilterTextChange = this.handleFilterTextChange.bind(this);
    this.handleInStockChange = this.handleInStockChange.bind(this);
  }
  
  handleFilterTextChange(e) {
    this.props.onFilterTextChange(e.target.value);
  }
  
  handleInStockChange(e) {
    this.props.onInStockChange(e.target.checked);
  }
  
  render() {
    return (
      <form>
        <input
          type="text"
          placeholder="Search..."
          value={this.props.filterText}
          onChange={this.handleFilterTextChange}
        />
        <p>
          <input
            type="checkbox"
            checked={this.props.inStockOnly}
            onChange={this.handleInStockChange}
          />
          {' '}
          Only show products in stock
        </p>
      </form>
    );
  }
}

// 全体
// ここに state を配置する
class FilterableProductTable extends React.Component {

  // state の初期状態を設定
  constructor(props) {
    super(props);
    this.state = {
      filterText: '',
      inStockOnly: false
    };
    
    this.handleFilterTextChange = this.handleFilterTextChange.bind(this);
    this.handleInStockChange = this.handleInStockChange.bind(this);
  }

  handleFilterTextChange(filterText) {
    this.setState({
      filterText: filterText
    });
  }
  
  handleInStockChange(inStockOnly) {
    this.setState({
      inStockOnly: inStockOnly
    })
  }

  render() {
    return (
      <div>
        <SearchBar
          filterText={this.state.filterText}
          inStockOnly={this.state.inStockOnly}
          onFilterTextChange={this.handleFilterTextChange}
          onInStockChange={this.handleInStockChange}
        />
        <ProductTable
          products={this.props.products}
          filterText={this.state.filterText}
          inStockOnly={this.state.inStockOnly}
        />
      </div>
    );
  }
}

// 商品データ
const PRODUCTS = [
  {category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
  {category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
  {category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
  {category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
  {category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
  {category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];

// レンダリング
ReactDOM.render(
  <FilterableProductTable products={PRODUCTS} />,
  document.getElementById('container')
);

上記のサンプルソースをHTMLファイルにしたものが以下です。

https://programming-world.net/sample/react_doc/howto1-2.html

上記コードでは、FilterableProductTableコンポーネントにfilterTextとinStockOnlyという名前の state を配置しています。

FilterableProductTableコンポーネントのコンストラクタで2つのstateの初期値を設定して、ProductTableコンポーネントとSearchBarコンポーネントに props として渡しています。

SearchBarコンポーネントがレンダリングするinput要素には、onChange={this.handleFilterTextChange}とonChange={this.handleInStockChange}が指定されています。

検索の入力欄やチェックボックスが変更された際には、 handleFilterTextChange()メソッドや handleInStockChange()メソッドが実行されて、FilterableProductTableコンポーネント内のsetState()が実行されて、その結果としてアプリが更新されます。

まとめ

今回は、Reactでアプリ作成を進めていく手順を実例で紹介しました。難しく感じる部分もあるかもしれませんが、大まかな流れはつかめたのではないでしょうか。

Reactでは、コンポーネントを適切に分けておくことで再利用性が高まります。また、後からコードを読み直した際に見晴らしのよいプログラムとなります。

Reactによる開発は、一見するとコード量が多く感じられるかもしれませんが、開発するアプリが大規模になったり、複数のエンジニアが参加する開発では、再利用性の高さと見晴らしのよさにありがたみを感じることになるでしょう。

◆ ◆ ◆

「React公式ドキュメントを読む」シリーズは、今回でひと区切りとなります。 Reactを使いこなすにはさらに学習や経験が必要ですが、おおまかに Reactプログラミングがどのようなものかをつかむ基礎編はこれで修了です。