AsaDesign

Reactの流儀

参考:React の流儀

実装の考え方を学びます。

React を使って検索可能な商品データテーブルを作成します。

ステップ1:できるだけ小さく分けて、階層を整理する

以下のJSONデータが来ると想定

[
  { category: "Fruits", price: "$1", stocked: true, name: "Apple" },
  { category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit" },
  { category: "Fruits", price: "$2", stocked: false, name: "Passionfruit" },
  { category: "Vegetables", price: "$2", stocked: true, name: "Spinach" },
  { category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin" },
  { category: "Vegetables", price: "$1", stocked: true, name: "Peas" }
]

細かく分けて…

  • FilterableProductTable(灰色)はアプリ全体のコンテナ。
  • SearchBar(青)はユーザ入力を受け取る。
  • ProductTable(紫)はユーザ入力に従ってリストを表示およびフィルタリングする。
  • ProductCategoryRow(緑)はカテゴリごとの見出しを表示する。
  • ProductRow(黄)は個々の製品に対応する行を表示する。

階層化します。

  • FilterableProductTable ←メインコンポーネントとなる
    • SearchBar
    • ProductTable
      • ProductCategoryRow
      • ProductRow

ステップ2:まずは見た目を作る

propはこの時点で使ってOK。動的な部分やstate(状態管理)はまだ実装しません。

単純な例ではトップダウンで作業する方が簡単であり、大規模なプロジェクトではボトムアップで進める方が簡単です。

全体のコード
function ProductCategoryRow({ category }) {
  return (
    <tr>
      <th colSpan="2">
        {category}
      </th>
    </tr>
  );
}

function ProductRow({ product }) {
  const name = product.stocked ? product.name :
    <span style={{ color: 'red' }}>
      {product.name}
    </span>;

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

function ProductTable({ products }) {
  const rows = [];
  let lastCategory = null;

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

function SearchBar() {
  return (
    <form>
      <input type="text" placeholder="Search..." />
      <label>
        <input type="checkbox" />
        {' '}
        Only show products in stock
      </label>
    </form>
  );
}

function FilterableProductTable({ products }) {
  return (
    <div>
      <SearchBar />
      <ProductTable products={products} />
    </div>
  );
}

const PRODUCTS = [
  {category: "Fruits", price: "$1", stocked: true, name: "Apple"},
  {category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
  {category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
  {category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
  {category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
  {category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
];

export default function App() {
  return <FilterableProductTable products={PRODUCTS} />;
}
まだ検索はできません。

ステップ3:各データの、stateの要不要を判別する

このアプリケーションで使われるデータは次の4つ。

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

この中で、state(状態管理)する必要があるものを考えます。

  • 時間が経っても変わらないものですか? そうであれば、state ではありません。
  • 親から props 経由で渡されるものですか? そうであれば、state ではありません。
  • コンポーネント内にある既存の state や props に基づいて計算可能なデータですか? そうであれば、それは絶対に state ではありません!

ということで、

  1. 元となる商品のリスト:JSONデータで送られてくる値なのでstate不要
  2. ユーザが入力した検索文字列:画面上の操作で変わる値なのでstate使用
  3. チェックボックスの値:画面上の操作で変わる値なのでstate使用
  4. フィルタ済みの商品のリスト:検索文字列とチェックボックスの値で計算できるため、state不要

ステップ4:どのコンポーネントにstateを所有させるか考える

ステップ3で、stateを使う値を絞りました。

  1. ユーザが入力した検索文字列
  2. チェックボックスの値

次は、ブラウザ表示するためにこれらの値が必要なコンポーネントを特定します。

以下を見ると、SearchBarとProductTableが該当するようです。

  • FilterableProductTable
    • ✅SearchBar:検索結果を表示するため、検索文字列もチェックボックスの値も必要
    • ✅ProductTable:フィルターした結果を表示するため、検索文字列もチェックボックスの値も必要
      • ProductCategoryRow
      • ProductRow

SearchBarとProductTableの共通の親コンポーネントはFilterableProductTableです。

なので、検索文字列とチェックボックスのstateはFilterableProductTableに持ってもらいます。

function FilterableProductTable({ products }) {
  const [filterText, setFilterText] = useState('');
  const [inStockOnly, setInStockOnly] = useState(false);

親コンポーネントからpropsとして、SearchBarとProductTableへ値を渡します。

<div>
  <SearchBar 
    filterText={filterText} 
    inStockOnly={inStockOnly} />
  <ProductTable 
    products={products}
    filterText={filterText}
    inStockOnly={inStockOnly} />
</div>

現時点ではコードに直接検索キーワードを書けば動きますが、ブラウザからは文字列を入力できません。チェックボックスも機能していません。

ステップ5:子から親へのデータ更新

ユーザの入力に従って state を変更するには、逆方向へのデータの流れをサポートする必要があります。

React ではこのデータフローを明示的に記述します。

SearchBar(子)が FilterableProductTable(親)の state を更新できるようにするには、これらの関数を SearchBar(子)に渡す必要があります。

function FilterableProductTable({ products }) {
  const [filterText, setFilterText] = useState('');
  const [inStockOnly, setInStockOnly] = useState(false);

  return (
    <div>
      <SearchBar 
        filterText={filterText} 
        inStockOnly={inStockOnly}
        // 子に関数を渡している
        onFilterTextChange={setFilterText}
        onInStockOnlyChange={setInStockOnly} />

state は FilterableProductTable によって所有されているため、このコンポーネントのみが setFilterTextsetInStockOnly を呼び出すことができます。

次に、SearchBar(子)の中で onChange イベントハンドラを追加し、それらから親 state を設定します。

function SearchBar({
  ...
  onFilterTextChange,
  onInStockOnlyChange
}) {
  return (
    <form>
      <input
        type="text"
        value={filterText}
        placeholder="Search..."
        // onChangeイベントハンドラで親stateを設定
        onChange={(e) => onFilterTextChange(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={inStockOnly}
          // onChangeイベントハンドラで親stateを設定
          onChange={(e) => onInStockOnlyChange(e.target.checked)}

これで完成です。