🙆

【React】コンポーネントもネストが深くなってはいけないことを実感した話

2024/11/10に公開

読みやすいコードを書く時の手法として、ネストを深くしないということはよく知られたことだと思います。

例えば、以下のようにネストの深いコードは読みにくいと思います。
条件分岐が複雑になるためです。

const processOrder = (order: { quantity: number, price: number }) => {
  if (order.quantity <= 0) {
    console.log('無効な数量です');
  } else {
    if (order.price <= 0) {
      console.log('無効な価格です');
    } else {
      const total = order.quantity * order.price;
      console.log(`注文の合計金額は: ${total}円です`);
    }
  }
};

以下のコードは先述のコードと同じ意味ですが、
ネストが深くないので、可読性が向上していることを実感できると思います。

const processOrder = (order: { quantity: number, price: number }) =>  {
  if (order.quantity <= 0) {
    console.log('Invalid quantity');
    return;
  }

  if (order.price <= 0) {
    console.log('Invalid price');
    return;
  }

  const total = order.quantity * order.price;
  console.log(`Order total: ${total}`);
}

これは、Reactのコンポーネントの親子関係にも言える話であるということを実感したという話です。

問題のコード

SongCard.tsx
import { useState } from "react";
import {
  Song,
  colorsMap,
} from "../lib/data";
import Image from "next/image";
import YouTubePlayer from "./YouTubePlayer";

const SongCard = ({ song }: { song: Song | null }) => {
  const [selectedColors, setSelectedColors] = useState<string[]>([
    "空色",
    "空色",
  ]);
  if (!song) return null;

  const handleColorSelect = (color: string, index: number) => {
    const updatedColors = [...selectedColors];
    updatedColors[index] = color;
    setSelectedColors(updatedColors);
  };

  return (
    <div className="song-card">
      <YouTubePlayer videoId={song.mvId} />

      {/* 中略 */}

      <div className="color-selection">
        {selectedColors.map((color, index) => (
          <select
            key={index}
            value={color}
            onChange={(e) => handleColorSelect(e.target.value, index)}
            style={{ backgroundColor: colorsMap[color] }}
          >
            {Object.keys(colorsMap).map((colorName, colorIndex) => (
              <option key={colorIndex} value={colorName}>
                {colorName}
              </option>
            ))}
          </select>
        ))}
      </div>

      {/* 略 */}
  );
};

export default SongCard;

ざっくりと上記のコンポーネントで行なっていることを説明すると、以下のようになります。

  • YouTubeの動画が、importしているYouTubePlayerコンポーネントによって埋め込まれている(再生ボタンを押すと再生されるようになっている)
  • 色をプルダウンで選択できる
  • 選んだ色はstateで管理している

何が問題かというと、YouTubeの動画を再生している状態で色を選ぶと、
再レンダリングされてYouTubeの動画が画面を初期表示した時の状態に戻るということです。

再レンダリングされる仕組みとしては、以下のようになります。

  1. SongCardコンポーネントのstateが、プルダウンで色を選ぶと更新される(stateで色を管理しているため)
  2. stateが更新されたので、SongCardコンポーネントの再レンダリングが行われる
  3. YouTubePlayerコンポーネントについても、親コンポーネントであるSongCardコンポーネントのstateが更新されたので再レンダリングされる

これは単純な例ですが、実務ではもっとたくさんのコンポーネントが作られるはずです。
これらについて複雑な階層関係があると、上記のような予期せぬ挙動の調査が難しいのは想像に難くないでしょう。

また、コンポーネントの親子関係の複雑化は以下のような問題を招くと考えられます。

  • 過剰な再レンダリングによるパフォーマンスの低下
  • コンポーネント間の依存関係が発生するので、テストが困難になる
  • 複雑な依存関係による思いもよらないバグの発生が恐怖となり、実装速度が低下する
  • コンポーネントを再利用しにくくなる

どうすればよかったのか

以下のように、SongCardコンポーネントとYouTubePlayerコンポーネントに親子関係を持たせずに呼び出せば良いです。

game.tsx
import { useState, useEffect } from "react";
import SongCard from "../components/SongCard";
import { getRandomSong, Song } from "../lib/data";
import YouTubePlayer from "@/components/YouTubePlayer";

const Game = () => {
  const [currentSong, setCurrentSong] = useState<Song | null>(null); 

  useEffect(() => {
    setCurrentSong(getRandomSong());
  }, []);

  return (
    <div>
      <YouTubePlayer videoId={currentSong?.mvId ?? ""} />
      <SongCard song={currentSong} />
    </div>
  );
};

export default Game;

最後に

シンプルであることって大切ですね。

Discussion