🌊

Reactハンズオンラーニングをやってみた

2021/10/17に公開

はじめに

Reactハンズオンラーニングはオライリー・ジャパンが発行しているReactの解説書です。自分はReactを実務で数ヶ月使用した程度なのですが、レベル感としては丁度よく得るものが多くありました。
中でも6章からのアプリケーションを実装しながらReactコンポーネントをリファクタリングしていく解説は、React上級者がどのように考えてコードの品質を高めていくのかを追体験でき、非常に勉強になりました。
本記事ではその過程をTypeScript化しながら実際にハンズオンでやってみた記録になります。要約した記述になりますので少しでも興味が湧いた方は是非お手に取って、自分でやってみることを強く勧めます。

https://www.amazon.co.jp/Reactハンズオンラーニング-第2版-―Webアプリケーション開発のベストプラクティス-Alex-Banks/dp/4873119383

準備

下記のようなアプリケーションを作っていきます。

まずはプロジェクトを作成してスターを表示するところまでやってみましょう。

npx create-react-app star-rating --template typescript
cd star-rating

不要なファイルを削除します。削除後のディレクトリ構成は下記になります

star-rating
├── README.md
├── node_modules
├── package.json
├── public
│   └ index.html
├── src
│   ├── App.tsx
│   └── index.tsx
└── yarn.lock

src/index.tsxを以下のように書き換えます

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

星のアイコンを表示する為にreact-iconsパッケージをインストールします。

yarn add react-icons

srcディレクトリにStarRating.tsxファイルを作成して以下のコードを記述します。

import { FaStar } from "react-icons/fa";

export default function StarRating() {
  return (
    <>
      <FaStar color="red" />
      <FaStar color="red" />
      <FaStar color="red" />
      <FaStar color="grey" />
      <FaStar color="grey" />
    </>
  );
}

src/App.tsxを以下のように書き換えます。

import StartRating from "./StarRating";

export default function App() {
  return <StartRating />;
}

yarn startして表示を確認してみましょう。
以下のように表示されていたら成功です。

しかし現在はスターをSVGで表示しているに過ぎないので、動的に表示を変化させることができません。次章からステートを管理して表示を動的に変更できるようにしていきます。

useStateを使ってクリックイベントに対応する

以下のように選択中の星は赤色に、選択していない星はグレーにする関数を記述します。

const Star = ({ selected = false }) => (
  <FaStar color={selected ? "red" : "gray"} />
)

レーティングは5段階評価が多い印象ですが、今回は下記のような処理を追加し星の数を可変にします。これにより任意の段階でレーティング評価できるコンポーネントを作成します。

export default function StarRating({ totalStarts = 5 }) {
  retrun [...Array(totalStarts)].map((_, i) => <Star key={i} /> );
}

useStateを使ってコンポーネントにステートを追加します。最終的なStarRating.tsxは下記のようになります。

src/StarRating.tsx
import { useState } from "react";
import { FaStar } from "react-icons/fa";

const Star = ({ selected = false }) => (
  <FaStar color={selected ? "red" : "gray"} />
);

export default function StarRating({ totalStars = 5 }) {
  const [selectedStars] = useState(3);
  return (
    <>
      {[...Array(totalStars)].map((n, i) => (
        <Star key={i} selected={selectedStars > i} />
      ))}
      <p>
        {selectedStars} of {totalStars} stars
      </p>
    </>
  );
}

これでuseState内の初期値によってレーティングのカラーを動的に変化させることができるようになりました。

useStateの値をユーザーのクリックに応じて変化させることができれば、インタラクティブにスターの色を変化させることができるはずです。StarRating.tsx内のStar関数を別コンポーネントに切り離し、onClickイベントハンドラを設定します。

src/Star.tsx
import { FaStar } from "react-icons/fa";

const Star = ({ selected = false, onSelect = () => {} }) => (
  <FaStar color={selected ? "red" : "gray"} onClick={onSelect} />
);

export default Star;

StarRating.tsxを以下のように書き換え、Starコンポーネントがクリックされるたびにstateの値が変化するようにします。

src/StarRating.tsx
import { useState } from "react";
import Star from "./Star";

export default function StarRating({ totalStars = 5 }) {
  const [selectedStars, setSelectedStars] = useState(3);
  return (
    <>
      {[...Array(totalStars)].map((n, i) => (
        <Star
          key={i}
          selected={selectedStars > i}
          onSelect={() => setSelectedStars(i + 1)}
        />
      ))}
      <p>
        {selectedStars} of {totalStars} stars
      </p>
    </>
  );
}

これによりクリックされたスターに応じてstateの値が変更され、StarRatingコンポーネントが再描画されます。以下のようにスターの色がインタラクティブに変化するようになりました。

アプリケーション全体のステート管理

レーティングの基本的な機能は追加できました。ここから色情報のリストを管理する「色見本アプリ」を実装し、それぞれの色がレーティングの値を持つようにします。

これを愚直に実装しようとすると、多くのコンポーネントがstateの値を持ってしまうアプリケーションになります。これはあまり望ましい状態では無いようで、複数のコンポーネントがstateを持つと機能追加やデバッグが難しくなるという副作用があるようです。

このような場合アプリケーションのstateを1箇所で管理する方が効率的で、1例として最上位のコンポーネントが全てのstateを管理する方法が挙げられます。最上位のコンポーネントからstateを子コンポーネントにプロパティ値として渡し、アプリケーション全体にデータを反映させるよう実装していくことでstate管理をシンプルな状態に保ちます。

まずアプリケーションで使用するデータを用意します。

src/color-data.json
[
  {
    "id": "0175d1f0-a8c6-41bf-8d02-df5734d829a4",
    "title": "ocean at dusk",
    "color": "#00c4e2",
    "rating": 5
  },
  {
    "id": "83c7ba2f-7392-4d7d-9e23-35adbe186046",
    "title": "lawn",
    "color": "#26ac56",
    "rating": 3
  },
  {
    "id": "a11e3995-b0bd-4d58-8c48-5e49ae7f7f23",
    "title": "bright red",
    "color": "#ff0000",
    "rating": 0
  }
]

今回のアプリではルートコンポーネントであるAppで全てのstateを管理するようにします。App以外のコンポーネントはstateを持たない為、useStateが使われるのもAppコンポーネントのみになります。

以下のようにApp.tsxを書き換えてstate値をcolor-data.jsonから読み出して配下のコンポーネントに渡すようにします。

src/App.tsx
import { useState } from "react";
import colorData from "./color-data.json";
import ColorList from "./ColorList.tsx";

export default function App() {
  const [colors] = useState(colorData);
  return <ColorList colors={colors} />;
}

src配下にColorList.tsxを作成します。

src/ColorList.tsx
import Color from "./Color";

type ColorProps = {
  id: string;
  title: string;
  color: string;
  rating: number;
};

export default function ColorList({ colors = [] }: { colors: ColorProps[] }) {
  if (!colors.length) return <div>No Colors Listed.</div>;

  return (
    <div>
      {colors.map((color) => (
        <Color key={color.id} {...color} />
      ))}
    </div>
  );
}

このコンポーネントは親コンポーネントであるApp.tsxから渡されたプロパティからcolors配列を取り出しています。配列の長さが0の場合、ユーザーにメッセージを表示するようにしています。

配列がデータを含んでいる場合はArray.mapColorコンポーネントに色の情報を1つずつ渡すようにしています。Colorコンポーネントを追加して色の情報を表示するようにします。

src/Color.tsx
import StarRating from "./StarRating";

export default function Color({
  title,
  color,
  rating,
}: {
  title: string;
  color: string;
  rating: number;
}) {
  return (
    <section>
      <h1>{title}</h1>
      <div style={{ height: 50, backgroundColor: color }} />
      <StarRating selectedStars={rating} />
    </section>
  );
}

ColorコンポーネントからratingのデータをStarRating.tsxにプロパティ値として渡します。StarRating.tsxを以下のように書き換えます。

src/StarRating.tsx
import Star from "./Star";

export default function StarRating({ totalStars = 5, selectedStars = 0 }) {
  return (
    <>
      {[...Array(totalStars)].map((n, i) => (
        <Star key={i} selected={selectedStars > i} />
      ))}
      <p>
        {selectedStars} of {totalStars} stars
      </p>
    </>
  );
}

特筆すべきは前に実装したStarRating.tsxと比較して今回はコンポーネントがstateの値を持たなくなったということです。stateを持たない関数コンポーネントは純粋関数と呼ばれ、同じプロパティ値が渡されれば必ず同じ結果が描画されます。純粋関数はテストがしやすくコードの見通しも良くなるという利点があります。

ここまでの実装でデータを描画した結果は下記になります。

ユーザーの操作をコンポーネントツリーの下から上に伝える

しかし現在のままではcolor-data.jsonの値を表示しているに過ぎない為、色の情報を評価したり削除したりすることができません。

ユーザーの操作は末端のコンポーネントに対して行われますが、色の情報は最上位のAppコンポーネントにstateとして保持されています。情報を動的に変化させる為には何らかの方法でユーザーの操作を末端のコンポーネントからルートコンポーネントに伝え、stateの値を変化させる必要があります。

onRemove関数とonRate関数を追加して、stateの削除/更新ができるようにしていきます。

src/Color.tsx
import StarRating from "./StarRating";
import { FaTrash } from "react-icons/fa";

export default function Color({
  id,
  title,
  color,
  rating,
  onRemove = () => {},
  onRate = () => {},
}: {
  id: string;
  title: string;
  color: string;
  rating: number;
  onRemove: (id: string) => void;
  onRate: (id: string, rating: number) => void;
}) {
  return (
    <section>
      <div style={{ display: "flex", alignItems: "center" }}>
        <h1>{title}</h1>
        <button
          style={{ height: 36, marginLeft: 10 }}
          onClick={() => onRemove(id)}
        >
          <FaTrash />
        </button>
      </div>
      <div style={{ height: 50, backgroundColor: color }} />
      <StarRating
        selectedStars={rating}
        onRate={(rating: number) => onRate(id, rating)}
      />
    </section>
  );
}

<button>要素を追加してonClickイベントハンドラが設定されています。ボタンがクリックされるとonRemove関数が発火しますがColorコンポーネント自体は処理内容に一切感知しません。親コンポーネントにイベントを通知するだけで、コンポーネントを純粋関数のままに保てています。
ColorList.tsxも同様に実装していきます。

src/ColorList.tsx
import Color from "./Color";

type ColorProps = {
  id: string;
  title: string;
  color: string;
  rating: number;
};

export default function ColorList({
  colors = [],
  onRemoveColor = () => {},
  onRateColor = () => {}
}: {
  colors: ColorProps[],
  onRemoveColor: (id: string) => void,
  onRateColor: (id: string, rating: number) => void,
}) {
  if (!colors.length) return <div>No Colors Listed. (Add a Color)</div>;

  return (
    <div>
      {colors.map(color => (
        <Color
          key={color.id}
          {...color}
          onRemove={onRemoveColor}
          onRate={onRateColor}
        />
      ))}
    </div>
  );
}

最後にApp.tsxを以下のように記述します

src/App.tsx
import { useState } from "react";
import colorData from "./color-data.json";
import ColorList from "./ColorList";

export default function App() {
  const [colors, setColors] = useState(colorData);
  return (
    <ColorList
      colors={colors}
      onRateColor={(id, rating) => {
        const newColors = colors.map((color) =>
          color.id === id ? { ...color, rating } : color
        );
        setColors(newColors);
      }}
      onRemoveColor={(id) => {
        const newColors = colors.filter((color) => color.id !== id);
        setColors(newColors);
      }}
    />
  );
}

onRateColor``onRemoveColorで子コンポーネントから通知を受け取り、stateの値を変化させる処理を行います。結果的にstateをルートコンポーネント一箇所のみで管理したまま、アプリケーションの実装ができています。

StarRating.tsxonRate関数を追加し、ユーザーの操作を親コンポーネントに通知するようにします。

src/StarRating.tsx
import Star from "./Star";

export default function StarRating({
  totalStars = 5,
  selectedStars = 0,
  onRate = () => {},
}: {
  totalStars?: number;
  selectedStars: number;
  onRate: (num: number) => void;
}) {
  return (
    <>
      {[...Array(totalStars)].map((n, i) => (
        <Star
          key={i}
          selected={selectedStars > i}
          onSelect={() => onRate(i + 1)}
        />
      ))}
      <p>
        {selectedStars} of {totalStars} stars
      </p>
    </>
  );
}

下記のように動作できていれば成功です

おわりに

Reactハンズオンラーニングではここからさらにアプリケーションを拡張していき、リファクタリングを行ってコードの品質を高めつつ実装を行なっていきます。

非常にわかりやすい解説で初級者から上級まで幅広いエンジニアにおすすめできる解説書だと思います。興味のある方はぜひ手に取ってみてください。

Discussion