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を渡してしまっていることが挙げられます。上記のdisplayedImagesとisMoreのuseStateが該当します。
React公式は「props を state にコピーしない」というセクションで、この点について、親コンポーネントが異なるpropsを渡してきた場合にstateは更新されないこと、また、stateは初回のレンダー時のみに初期化されると解説しています。initialStateにpropsの値をコピーすることになってしまうため、特定のpropsの更新を意図的に無視したい特殊ケースでは有効であるそうです。
例の<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 とは、アプリが記憶する必要のある、変化するデータの最小限のセットのことである、と考えましょう。
ボタン押下というインタラクションにより変化する最小限のデータとは、表示する画像の数と言えます。これを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を指定すれば良いことになります。

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