🪁

stateを最小限に捉えることで、Reactコンポーネントを改善する

に公開

こんにちは。本稿では、SPAアプリケーションにおいて、Reactコンポーネントをリファクタリングしていた際の気づきを紹介します。(何番煎じかは分かりませんが...)では、早速みていきます。

例:問題のあるReactコンポーネント

APIで取得してきた画像データを表示するページを作成しており、次のような<Gallery>コンポーネントがあるとします。親コンポーネントからrawImagesをpropsとして受け取り、表示する画像をstateとして管理しています。そして、ボタン押下ごとに表示する画像の数を3枚ずつ増やすロジックです。このコードの問題点は何でしょうか。

const DISPLAYED_IMAGES_PER_LOAD = 3;

const Gallery = ({ rawImages }: Props) => {
  const [displayedImages, setDisplayedImages] = useState<ImageInfo[]>(rawImages.slice(0, DISPLAYED_IMAGES_PER_LOAD));
  const [isMore, setIsMore] = useState<boolean>(DISPLAYED_IMAGES_PER_LOAD < rawImages.length);

  const loadMore = () => {
    if (isMore) {
      const nextDisplayedImages = displayedImages.concat(
        rawImages.slice(displayedImages.length, displayedImages.length + DISPLAYED_IMAGES_PER_LOAD)
      );
      setDisplayedImages(nextDisplayedImages);

      if (rawImages.length <= nextDisplayedImages.length) {
        setIsMore(false);
      }
    }
  }

  return (
    <>
      <div className={styles.gallery}>
        {displayedImages.map(item => <ImageItem key={item.id} imageInfo={item} />)}
      </div>
      {isMore && <button className={styles.btn} onClick={loadMore}>Load More</button>}
    </>
  )
}

まず問題点として、useStateのinitialState(初期値)にpropsであるrawImagesを渡してしまっていることが挙げられます。上記のdisplayedImagesisMoreのuseStateが該当します。

React公式は「props を state にコピーしない」というセクションで、この点について、親コンポーネントが異なるpropsを渡してきた場合にstateは更新されないこと、また、stateは初回のレンダー時のみに初期化されると解説しています。initialStateにpropsの値をコピーすることになってしまうため、特定のpropsの更新を意図的に無視したい特殊ケースでは有効であるそうです。
https://ja.react.dev/learn/choosing-the-state-structure#don-t-mirror-props-in-state

例の<Gallery>コンポーネントは親からpropsとして渡されるrawImagesの値が異なる場合に正しく機能しないコードであると言えます。これを解消するために、useEffectを利用し、値を検知してset関数を呼ぶようにするという考えもあるかもしれませんが、それはuseEffectの本来の使い方と逸脱してしまうため採用できません。

そこで、React公式はpropsの値をそのまま利用することを推奨しています。今回の場合は、このようにすることが必要です。

const Gallery = ({ rawImages }: Props) => {
  const displayedImages = rawImages.slice(0, DISPLAYED_IMAGES_PER_LOAD);
  const isMore = displayedImages.length < rawImages.length;

一方で、<Gallery>コンポーネントはボタン押下というインタラクションによって、表示する画像の数を増やす役割も担っており、このままでは上手くいきません。よって、元のように表示する画像をstateとして管理するのも一見すると妥当な選択に思います。ここで、<Gallery>コンポーネントがstateとして持つべき値を再度考えてみます。

改善

では、今回のコードはどのように改善できるでしょうか。stateとして取り扱うべき値についてReact公式の考え方について確認します。

state とは、アプリが記憶する必要のある、変化するデータの最小限のセットのことである、と考えましょう。

https://ja.react.dev/learn/thinking-in-react#step-3-find-the-minimal-but-complete-representation-of-ui-state

ボタン押下というインタラクションにより変化する最小限のデータとは、表示する画像のと言えます。これをstateとして管理します。他方、表示される画像自体rawImages)は<Gallery>コンポーネント自身のレンダー間では不変です。
表示する画像の数をstateとすることで、再レンダーごとに現在表示する画像を計算できます。すると、以下のようにシンプルなコンポーネントに改善できます。

const DISPLAYED_IMAGES_PER_LOAD = 3;

export const Gallery = ({ rawImages }: Props) => {
  const [displayedNum, setDisplayedNum] = useState<number>(DISPLAYED_IMAGES_PER_LOAD);
  const displayedImages = rawImages.slice(0, displayedNum);
  const isMore = displayedImages.length < rawImages.length;

  const loadMore = () => {
    if (isMore) {
      setDisplayedNum((prevNum) => prevNum + DISPLAYED_IMAGES_PER_LOAD);
    }
  }

  return (
    <>
      <div className={styles.gallery}>
        {displayedImages.map(item => <ImageItem key={item.id} imageInfo={item} />)}
      </div>
      {isMore && <button className={styles.btn} onClick={loadMore}>Load More</button>}
    </>
  )
}

もし親コンポーネントから異なるrawImagesが渡される場合は、displayedNumをリセットしたいですから、<Gallery>コンポーネントにkeyを指定すれば良いことになります。

Image Gallery
動作確認

まとめ

本稿では、stateに着目したReactコンポーネントの改善についてみてきました。「stateで本当に管理すべき値は何か」を考えることで、シンプルな記述で実装できることが分かりました。
この記事が皆さんのReact開発の一助となれば幸いです。もし記事中の内容に間違い等ありましたら、ご指摘いただければと思います。

Discussion