【React】ハンズオンでState管理を学ぶ

State管理について、こちらの書籍より学ばせていただいた内容をまとめています。

書籍の説明

書籍名: Reactハンズオンラーニング 第2版 ―Webアプリケーション開発のベストプラクティス
著者: Alex Banks 著
出版社: オライリージャパン 発行
発売日: 2021/8/6

個人的には、脱初心者レベルにはとても良い内容となっています。

最上位でstateを管理

Reactでは絶対に使うstate。このstateをアプリ内の全てのコンポーネントが持つというのは、実は望ましい姿ではないんです。

なぜなら、複数のコンポーネントがstateを持つと、機能追加やデバッグが難しくなるからです。

そのため、stateを一箇所で管理した方が効率的です。

方法としては、最上位のコンポーネントで全てのstateを管理し、子コンポーネントにはプロパティとして渡すようにします。

実際にやってみよう!

ルートコンポーネントApp.jsでcolors配列を取得し、子コンポーネントColorListにはプロパティ経由でcolorsを伝えます。

import React, { useState } from "react";
import colorData from "./color-data.json";
import ColorList from "./components/ColorList";

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

export default App;

colorDataには以下の内容を登録しており、colorsというstateとして使用します。

App → ColorList

[
  {
    "id": "123123",
    "title": "ocean at dusk",
    "color": "#00c4e2",
    "rating": 5
  },
  {
    "id": "456456",
    "title": "lawn",
    "color": "#26ac56",
    "rating": 3
  },
  {
    "id": "789789",
    "title": "bright red",
    "color": "#ff0000",
    "rating": 0
  }
]

colorsを受け取るColorListを見てみます。

import Color from "./Color";

export default function ColorList({ colors = [] }) {
  // colorsが長さ0の配列ならメッセージを表示。配列にデータがある場合、Colorコンポーネントの配列に変換
  if (!colors.length) return;
  <div>No Colors Listed.</div>;
  return (
    <div>
      {colors.map((color) => (
        <Color key={color.id} {...color} />
      ))}
    </div>
  );
}

受け取ったcolorsはmapを使用し、スプレッド構文の{…color}によって、colorデータが1オブジェクトごとに取り出される。

App → ColorList → Color

import StarRating from "./StarRating"

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

そのオブジェクトから、スプレッド構文の{…color}から取り出された、3つのプロパティtitle, color, ratingを受け取る。

selectedStarsにratingを渡し、赤色表示されるstarの数が決まる。

App → ColorList → Color → StarRating

import React from 'react'
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>
    </>
  )
}

App → ColorList → Color → StarRating → Star

import { FaStar } from 'react-icons/fa'

// f => fで受け取った値をそのまま返すダミー関数
export const Star = ({ selected = false, onSelect = f => f }) => (
  <FaStar color={selected ? "red" : "gray"} onClick={onSelect}/>
)

以上によりできたのがこちら

App〜Starまで、コンポーネント構成がかなり深くなりましたね。

コンポーネントのツリーの下から上に情報を伝える

上記のようにルートコンポーネントから、末端に情報を伝えることはできました。

では、Ratingの色を変更したり削除するなど、末端のコンポーネントを起点とする変更の場合はどうすればよいのでしょうか。

削除機能の追加とRatingの色変更機能を追加します。

今度は、末端の側から編集していきます。

import React from "react";
import { Star } from "./Star";

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

変更箇所

  • onRateの受け取りを追加する
  • StarにonSelectを追加し、Starアイコンがクリックされるたびに親コンポーネントであるColorに通知されるようにする

App → ColorList → Color → StarRating ← Star

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

export default function Color({
  id,
  title,
  color,
  rating,
  onRemove = (f) => f,
  onRate = (f) => f,
}) {
  return (
    <section>
      <h1>{title}</h1>
      <button onClick={() => onRemove(id)}>
        <FaTrash />
      </button>
      <div style={{ height: 50, backgroundColor: color }} />
      <StarRating
        selectedStars={rating}
        onRate={(rating) => onRate(id, rating)}
      />
    </section>
  );
}

変更箇所

  • idとonRemoveとonRateの受け取りを追加する
  • ゴミ箱アイコンを追加し、onClickイベントを設定
  • StarRatingにonRateを追加し、ratingの情報を親コンポーネントであるColorListに渡す

App → ColorList → Color ← StarRating ← Star

import Color from "./Color";

export default function ColorList({
  colors = [],
  onRemoveColor = (f) => f,
  onRateColor = (f) => f,
}) {
  if (!colors.length) return;
  <div>No Colors Listed.(Add a Color)</div>;
  return (
    <div className="color-list">
      {colors.map((color) => (
        <Color
          key={color.id}
          {...color}
          onRemove={onRemoveColor}
          onRate={onRateColor}
        />
      ))}
    </div>
  );
}

変更箇所

  • onRemoveColorとonRateColorの受け取りを追加する
  • ColorにonRemoveとonRateを追加し、親コンポーネントであるAppにイベントを通知する

App → ColorList ← Color ← StarRating ← Star

import React, { useState } from "react";
import colorData from "./color-data.json";
import ColorList from "./components/ColorList";

function App() {
  const [colors, setColors] = useState(colorData);
  return (
    <div>
      <ColorList
        colors={colors}
        onRateColor={(id, rating) => {
          const newColors = colors.map((color) =>
            color.id === id ? { ...color, rating } : color
          );
          setColors(newColors);
        }}
        onRemoveColor={(id) => {
          // 引数のidに対応するcolorのdataをcolorsの配列から削除
          // .filterによって新しい配列を生成している。
          const newColors = colors.filter((color) => color.id !== id);
          setColors(newColors);
        }}
      />
    </div>
  );
}

export default App;

変更箇所

  • ColorListに、onRateColorとonRemoveColorを追加する
  • 変更関数のsetColorsを設定する

App ← ColorList ← Color ← StarRating ← Star

やっとAppまで到達しました。

以上によりできたのがこちら

ちゃんと削除ボタンが機能して、Ratingの変更も行えていますね。

カラー選択と入力フォームを追加

次に、カラーの選択とタイトルを入力して、初期の3色3タイトルに追加ができる機能を実装していきます。

追加のためのコンポーネントを新しく作ります。

import React, { useState } from "react";

export default function AddColorForm({ onNewColor = (f) => f }) {
  const [title, setTitle] = useState("");
  const [color, setColor] = useState("#000000");

  const submit = (e) => {
    // e.preventDefault();とすることで、ボタンクリック時にPOTリクエストが送信されなくできる。
    // submitイベントはデフォルトでPOSTリクエストで入力値を送信するためこれを抑止する。
    e.preventDefault();
    // 関数プロパティ経由で親コンポーネントにstate値を通知
    onNewColor(title, color);
    setTitle("");
    setColor("");
  };

  return (
    <form onSubmit={submit}>
      <input
        value={title}
        onChange={(event) => setTitle(event.target.value)}
        type="text"
        placeholder="color title..."
        required
      />
      <input
        value={color}
        onChange={(event) => setColor(event.target.value)}
        type="color"
        required
      />
      <button>ADD</button>
      {/* この時点でのtitleとcolorを呼び出すことで、親コンポーネントに通知する */}
    </form>
  );
}

ここで、以下の部分のコードが2回繰り返しになっていることがわかります。

        value={title}
        onChange={(event) => setTitle(event.target.value)}

そのため、この部分をカスタムフック化して、繰り返し記述することのないようにしていきます。

カスタムフック

import { useState } from "react";

export const useInput = (initialValue) => {
  const [value, setValue] = useState(initialValue);
  return [
    { value, onChange: (e) => setValue(e.target.value) },
    // stateを初期値でリセット
    () => setValue(initialValue),
  ];
};

戻り値を配列の形で返しています。

このカスタムフックuseInputを使って、先程のAddColorFormコンポーネントを書き換えていきます。

import React from "react";
import { useInput } from "../hooks";

export default function AddColorForm({ onNewColor = (f) => f }) {
  const [titleProps, resetTitle] = useInput("");
  const [colorProps, resetColor] = useInput("#000000");

  const submit = (e) => {
    e.preventDefault();
    // 関数プロパティ経由で親コンポーネントにstate値を通知する際にvalueフィールドを指定
    onNewColor(titleProps.value, colorProps.value);
    // カスタムフックから取得したreset関数を呼び出し。
    resetTitle();
    resetColor();
  };

  return (
    <form onSubmit={submit}>
      <input
        {...titleProps}
        type="text"
        placeholder="color title..."
        required
      />
      <input {...colorProps} type="color" required />
      <button>ADD</button>
      {/* この時点でのtitleとcolorを呼び出すことで、親コンポーネントに通知する */}
    </form>
  );
}

変更箇所

  • useStateのimportが不要
  • カスタムフックのuseInputをimport
  • useStateをuseInputに置き換え、変数と更新関数も変更
  • onNewColorではvalueフィールドを指定
  • inputのvalueとonChangeはスプレッド構文で取得

入力値をstateに反映させる

タイトルとカラーを選択し、ADDをすると、stateを更新して追加されるようにしていきます。

上記で記述したAddColorコンポーネントの親コンポーネントであるAppコンポーネントを編集します。

import React, { useState } from "react";
import colorData from "./color-data.json";
import ColorList from "./components/ColorList";
import AddColorForm from "./components/AddColorForm";
// ユニークなidを生成するためにuuidを使用
import { v4 } from "uuid";

function App() {
  const [colors, setColors] = useState(colorData);
  return (
    <>
      <AddColorForm
        onNewColor={(title, color) => {
          // 色のオブジェクトが作成される
          const newColors = [
            ...colors,
            // スプレッド構文colorsと連結
            {
              id: v4(),
              rating: 0,
              title,
              color,
            },
          ];
          setColors(newColors);
        }}
      />
      <ColorList
        省略
      />
    </>
  );
}

export default App;

ADDボタンが押されると、onNewColor関数が呼び出され、引数にtitleとcolorが渡され、オブジェクトが作成される。

出来上がったのがこちら

Context

アプリの規模が大きくなるにつれて、上記のstateをRootコンポーネントのみで管理する方法は、非現実的なものであることが明るみになってきました。あまりにも深すぎます。。

それら中継(バケツリレーを)を不要とする方法がContextです。

Contextの使用方法

・Context Providerにデータを渡す。

・Context Consumerがデータを読み出す。

Context Providerを追加するために、createContextを使ってContextオブジェクトを作成します。

実際に使ってみる。

import React, { createContext } from "react";
import colors from "./color-data";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";

export const ColorContext = createContext();

ReactDOM.render(
  <ColorContext.Provider value={{ colors }}>
    <App />
  </ColorContext.Provider>,
  document.getElementById("root")
);

これが何を表すかというと、ColorContextは2つのコンポーネントを提供します。

  1. ColorContext.Provider
  2. ColorContext.Consumer

上記では、ColorContext.Providerにcolorsを渡して、Appコンポーネントを囲んでいる。

これにより、colorsのデータはApp配下の全てのコンポーネントから参照可能となる。

(colorsはAppの親であるため、アクセスする必要がない)

そのため、Appコンポーネントがstateを保持したり、配下のコンポーネントにstateを渡す必要がなくなった。

これにより、Appコンポーネントは次のように書き換えられるます。

import React from "react";
import ColorList from "./components/ColorList";
import AddColorForm from "./components/AddColorForm";

function App() {
  return (
    <>
      <AddColorForm />
      <ColorList />
    </>
  );
}

export default App;

劇的にシンプルになりましたね!!

AddColorFormとColorListのコンポーネントを描画するのみになりました。

コンポーネントのpropsは一切ありません!

useContextからデータを取得する

useContextにより子コンポーネントはどうなったでしょうか?

ColorListコンポーネントの変更前後を比較します。

変更前

import Color from "./Color";

export default function ColorList({
  colors = [],
  onRemoveColor = (f) => f,
  onRateColor = (f) => f,
}) {
  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>
  );
}

変更後

import { useContext } from "react";
import { ColorContext } from "../index";
import Color from "./Color";

export default function ColorList() {
  // Contextから直接colorsにアクセスできるためプロパティを引数として受け取る必要がない。
  const { colors } = useContext(ColorContext);
  if (!colors.length) return;
  <div>No Colors Listed.(Add a Color)</div>;
  return (
    <div>
      {colors.map((color) => (
        <Color key={color.id} {...color} />
      ))}
    </div>
  );
}

変更箇所

  • useContextフックをimport
  • index.htmlからColorContextオブジェクトをimport
  • onRemoveColorとonRateColorの受け取りが不要

colorsの配列をプロパティから取得する代わりに、useContext経由で取得しています。

そのため、直接colorsにアクセスできるようになったので、プロパティのonRemoveColorやonRateColorを引数として受け取る必要がなくなった。

ContextとStateの併用

ContextProviderでは、データを公開することが可能ですが、データを変更することはできなません。

それをするには、親コンポーネントでstateを保持して、その値をProviderコンポーネントに設定する必要があります。

データが変更される仕組み

親コンポーネントのstateが更新

→ 親コンポーネントが再描画

→ Provider配下のコンポーネントが新しいデータで再描画

というわけです。

Stateを持ち、ContextProviderを描画するコンポーネントを作ることができる、このようなコンポーネントをカスタムプロバイダーと呼びます。

ColorProviderという名前でこのカスタムプロバイダーを作り、addColor、removeColor、rateColorの3つの関数をコンテキスト経由参照できるようにします。

import React, { createContext, useState } from "react";
import colorData from "./color-data.json";

export const ColorContext = createContext();

export default function ColorProvider({ children }) {
  const [colors, setColors] = useState(colorData);

  const addColor = (title, color) =>
    setColors([
      ...colors,
      {
        id: v4(),
        rating: 0,
        title,
        color,
      },
    ]);

  const rateColor = (id, rating) => {
    setColors(
      colors.map((color) => (color.id === id ? { ...color, rating } : color))
    );
  };

  const removeColor = (id) =>
    setColors(colors.filter((color) => color.id !== id));

  return (
    // valueプロパティにcolorsを設定することによりchildren配下の全てのコンポーネントはcolorsを参照することが可能になる
    // setColorsも設定することで、配下のコンポーネントは変更もできるようになる
    <ColorContext.Provider value={{ colors, addColor, removeColor, rateColor }}>
      {children}
    </ColorContext.Provider>
  );
}

colorの参照、更新ができるカスタムプロバイダーをつくりました。

ContextとカスタムフックuseColorsの併用

データを公開するためのProviderと参照するためのカスタムフックuseColorsを一つのモジュールColorProviders.jsにまとめます。

import React, { createContext, useState, useContext } from "react";
import colorData from "./color-data.json";
import { v4 } from "uuid";

// データを公開するためのカスタムProvider
export const ColorContext = createContext();

// データを参照するためのカスタムフック
export const useColors = () => useContext(ColorContext);

export default function ColorProvider({ children }) {
  const [colors, setColors] = useState(colorData);
  
  以下省略

上記データを公開する側のindex.js

ColorProviderをimportしてAppを囲う。

import { createContext } from "react";
import ColorProvider from "./ColorProvider";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";

export const ColorContext = createContext();

ReactDOM.render(
  <ColorProvider>
    <App />
  </ColorProvider>,
  document.getElementById("root")
);

reportWebVitals();

これで、App配下の全てのコンポーネントはcolorsにアクセスできるようになりました。

それでは、App配下の全てのコンポーネントにcolorsを記述していきましょう。

ColorListコンポーネントもcolors配列にアクセスできるようにします。

import Color from "./Color";
import { useColors } from "../ColorProvider";

export default function ColorList() {
  const { colors } = useColors();

  以下省略

次にColorコンポーネントをカスタムフックuseColors経由でContextにアクセス。

import { FaTrash } from "react-icons/fa";
import StarRating from "./StarRating";
import { useColors } from "../ColorProvider";

export default function Color({ id, title, color, rating }) {
  const { rateColor, removeColor } = useColors();
  return (
    <section>
      <h1>{title}</h1>
      <button onClick={() => removeColor(id)}>
        <FaTrash />
      </button>
      <div style={{ height: 50, backgroundColor: color }} />
      <StarRating
        selectedStars={rating}
        onRate={(rating) => rateColor(id, rating)}
      />
    </section>
  );
}

最後に、AddColorFormコンポーネントでカスタムフックuseColorsを使う。

import { useInput } from "../hooks";
import { useColors } from "../ColorProvider";

export default function AddColorForm() {
  const [titleProps, resetTitle] = useInput("");
  const [colorProps, resetColor] = useInput("#000000");
  // useColors関数をaddColorsとして使用
  const { addColor } = useColors();

  const submit = (e) => {
    e.preventDefault();
    addColor(titleProps.value, colorProps.value);
    resetTitle();
    resetColor();
  };

まとめ

以上が、StarRatingとColorを表示するアプリの実装を通して、Stateについてでした。

上から下、下から上にデータを渡す方法、カスタムフックを使ったコードの簡略化、Contextを使った中継の削減など、このアプリ実装を通して学べることが盛りだくさんでした!

最後に、本記事は以下の書籍での学習内容をまとめています。個人的には、脱初心者レベルにはとても良い内容と感じています。

コメント