32blogby StudioMitsu
react15 min read

React再レンダリングを最適化する実践ガイド

Reactの不要な再レンダリングを防ぐmemo・useMemo・useCallbackの正しい使い方と、再レンダリングを特定するデバッグ手法を解説。

reactパフォーマンスmemouseMemouseCallback再レンダリング
目次

「Reactが遅い」と感じたとき、たいていの原因は不要な再レンダリングだ。コンポーネントが必要以上に何度も再実行されて、UIの更新に時間がかかっている。

僕自身、リストが数百件あるページでスクロールするたびに引っかかりを感じて、React DevToolsを開いてみたら全コンポーネントが毎フレーム再レンダリングされていた、という経験がある。

この記事では、再レンダリングが起きる仕組みを理解したうえで、memouseMemouseCallback を使って不要な再レンダリングを防ぐ実践的な方法を解説する。「なんとなく memo を貼っておけばいい」ではなく、「なぜこの場面で必要なのか」を理解して使えるようになるはずだ。

再レンダリングはなぜ起きるのか?

Reactの再レンダリングには3つのトリガーがある。

  1. 自身のstateが変わったsetState が呼ばれた)
  2. 親コンポーネントが再レンダリングされた
  3. useContextの値が変わった

2番目が特に重要だ。親が再レンダリングされると、子コンポーネントは全て再レンダリングされる(デフォルトの挙動)。

tsx
function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      {/* count が変わるたびに、ChildA と ChildB も再レンダリングされる */}
      <ChildA />
      <ChildB />
    </div>
  );
}

ChildAChildBcount を props で受け取っていないのに、Parent の state が変わるだけで再レンダリングされる。このコストが積み重なるとパフォーマンス問題になる。

再レンダリングはそれ自体は悪ではない

重要な前置き:再レンダリング自体は悪ではない。Reactは再レンダリングが速くなるよう設計されていて、実際の DOM 操作は差分だけに最小化されている。数十個のシンプルなコンポーネントが再レンダリングされても、体感速度に影響はほぼない。

最適化が必要なのは:

  • 重い計算を含むコンポーネント
  • 数百件以上のリストアイテム
  • 60fps を保ちたいアニメーション周り

「まず計測してから最適化」が鉄則だ。

React DevToolsで再レンダリングを可視化する

最適化の前に、どのコンポーネントが不要に再レンダリングされているかを確認する。

React DevTools(ブラウザ拡張)のProfilerタブを使う。

  1. React DevToolsをインストールする
  2. Profilerタブを開く
  3. 歯車アイコン(Settings)→「Highlight updates when components render」をオンにする
  4. アプリを操作すると、再レンダリングされたコンポーネントがハイライトされる

色の意味:

  • — 1回のレンダリング(少ない)
  • — 数回のレンダリング
  • — 多めのレンダリング
  • — 非常に多いレンダリング(要注意)

React.memoで不要な再レンダリングを防ぐ

React.memo を使うと、propsが変わっていない場合にコンポーネントの再レンダリングをスキップできる。

❌ 最適化前

tsx
function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("太郎");

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>count: {count}</button>
      <input value={name} onChange={e => setName(e.target.value)} />
      {/* count が変わるだけで NameDisplay が再レンダリングされる */}
      <NameDisplay name={name} />
    </div>
  );
}

function NameDisplay({ name }: { name: string }) {
  console.log("NameDisplay rendered"); // 毎回呼ばれる
  return <p>こんにちは、{name}さん</p>;
}

✅ React.memoで最適化

tsx
// React.memoでwrap。nameが変わったときだけ再レンダリングされる
const NameDisplay = memo(function NameDisplay({ name }: { name: string }) {
  console.log("NameDisplay rendered"); // nameが変わったときだけ呼ばれる
  return <p>こんにちは、{name}さん</p>;
});

memo はpropsを Object.is で比較する。プリミティブ値(文字列、数値、真偽値)なら正しく比較できる。

memoが効かないケース

tsx
function Parent() {
  const [count, setCount] = useState(0);

  // ❌ オブジェクトはレンダリングのたびに新しい参照が生成される
  const user = { name: "太郎", age: 20 };
  // ❌ 関数もレンダリングのたびに新しい参照が生成される
  const handleClick = () => console.log("clicked");

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      {/* user と handleClick の参照が毎回変わるので、memo が効かない */}
      <UserCard user={user} onClick={handleClick} />
    </div>
  );
}

const UserCard = memo(function UserCard({
  user,
  onClick,
}: {
  user: { name: string; age: number };
  onClick: () => void;
}) {
  console.log("UserCard rendered"); // 毎回呼ばれてしまう
  return (
    <div onClick={onClick}>
      {user.name}({user.age}歳)
    </div>
  );
});

userhandleClick は毎レンダリングで新しい参照が生成されるので、memo は「propsが変わった」と判断して再レンダリングを実行してしまう。これを解決するのが useMemouseCallback だ。

useMemoでオブジェクト参照を安定させる

useMemo は計算結果をメモ化して、依存値が変わらない限り同じ参照を返す。

tsx
function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("太郎");
  const [age, setAge] = useState(20);

  // ✅ name か age が変わったときだけ新しいオブジェクトを生成
  const user = useMemo(() => ({ name, age }), [name, age]);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>count: {count}</button>
      {/* count が変わっても user の参照は変わらない → UserCard の再レンダリングをスキップ */}
      <UserCard user={user} />
    </div>
  );
}

const UserCard = memo(function UserCard({
  user,
}: {
  user: { name: string; age: number };
}) {
  console.log("UserCard rendered");
  return <div>{user.name}({user.age}歳)</div>;
});

useMemoで重い計算をキャッシュする

参照安定化だけでなく、計算コストの高い処理をキャッシュするためにも使う。

tsx
function SearchResults({ items, query }: { items: Item[]; query: string }) {
  // ❌ 毎レンダリングでフィルタリングが実行される
  // const filtered = items.filter(item => item.name.includes(query));

  // ✅ items か query が変わったときだけフィルタリングを実行
  const filtered = useMemo(
    () => items.filter(item => item.name.toLowerCase().includes(query.toLowerCase())),
    [items, query]
  );

  return (
    <ul>
      {filtered.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

items が1000件あって、複雑なフィルタリングや並べ替えを行う場合は useMemo が効果的だ。一方、10件程度のシンプルな処理なら useMemo のオーバーヘッドの方が大きいこともある。

useCallbackで関数参照を安定させる

useCallback は関数をメモ化して、依存値が変わらない限り同じ関数参照を返す。

tsx
function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("太郎");

  // ✅ name が変わったときだけ新しい関数を生成
  const handleUserClick = useCallback(() => {
    console.log(`${name}がクリックされた`);
  }, [name]);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>count: {count}</button>
      {/* count が変わっても handleUserClick の参照は変わらない */}
      <UserCard onClick={handleUserClick} />
    </div>
  );
}

const UserCard = memo(function UserCard({ onClick }: { onClick: () => void }) {
  console.log("UserCard rendered");
  return <button onClick={onClick}>クリック</button>;
});

useMemoとuseCallbackの関係

useCallback(fn, deps)useMemo(() => fn, deps) と等価だ。関数を返すための useMemo のショートハンドと考えると覚えやすい。

tsx
// この2つは同じ
const fn1 = useCallback(() => doSomething(a, b), [a, b]);
const fn2 = useMemo(() => () => doSomething(a, b), [a, b]);

実践:重いリストの最適化

数百件のリストで各アイテムに操作ボタンがある場面を例に、最適化の全体像を見てみよう。

❌ 最適化前

tsx
function TodoList() {
  const [todos, setTodos] = useState(initialTodos); // 500件
  const [filter, setFilter] = useState("all");

  // フィルタリング: 毎レンダリングで実行
  const filtered = todos.filter(todo => {
    if (filter === "done") return todo.done;
    if (filter === "pending") return !todo.done;
    return true;
  });

  // 関数: 毎レンダリングで新しい参照が生成
  const handleToggle = (id: string) => {
    setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
  };

  const handleDelete = (id: string) => {
    setTodos(prev => prev.filter(t => t.id !== id));
  };

  return (
    <div>
      <FilterButtons filter={filter} onChange={setFilter} />
      <ul>
        {filtered.map(todo => (
          // TodoItem は filter が変わるたびに全件再レンダリングされる
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={handleToggle}
            onDelete={handleDelete}
          />
        ))}
      </ul>
    </div>
  );
}

✅ 最適化後

tsx
function TodoList() {
  const [todos, setTodos] = useState(initialTodos); // 500件
  const [filter, setFilter] = useState("all");

  // ✅ filter か todos が変わったときだけフィルタリングを実行
  const filtered = useMemo(
    () => todos.filter(todo => {
      if (filter === "done") return todo.done;
      if (filter === "pending") return !todo.done;
      return true;
    }),
    [todos, filter]
  );

  // ✅ 関数参照を安定させる(todos は関数内で使わないのでdepsに入れない)
  const handleToggle = useCallback((id: string) => {
    setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
  }, []); // 関数型更新なので todos を deps に入れなくていい

  const handleDelete = useCallback((id: string) => {
    setTodos(prev => prev.filter(t => t.id !== id));
  }, []);

  return (
    <div>
      <FilterButtons filter={filter} onChange={setFilter} />
      <ul>
        {filtered.map(todo => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={handleToggle} // 参照が安定している
            onDelete={handleDelete} // 参照が安定している
          />
        ))}
      </ul>
    </div>
  );
}

// ✅ memo でwrap。todo・onToggle・onDelete が変わらなければスキップ
const TodoItem = memo(function TodoItem({
  todo,
  onToggle,
  onDelete,
}: {
  todo: Todo;
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
}) {
  return (
    <li>
      <span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
        {todo.text}
      </span>
      <button onClick={() => onToggle(todo.id)}>完了</button>
      <button onClick={() => onDelete(todo.id)}>削除</button>
    </li>
  );
});

最適化のポイント:

  1. useMemo でフィルタリングをキャッシュ — 500件のフィルタ処理を毎レンダリングから解放
  2. useCallback でハンドラ参照を安定化 — 関数型更新 prev => を使えば todos をdepsに入れなくていい
  3. memo で TodoItem をラップ — 関係ない変更では再レンダリングをスキップ

コンテキストによる不要な再レンダリングを防ぐ

useContext を使うと、コンテキストの値が変わったときにそのコンテキストを使っている全コンポーネントが再レンダリングされる。これが意図しない再レンダリングの原因になりやすい。

tsx
// ❌ 一つのコンテキストに全てを詰め込む
const AppContext = createContext({
  user: null,
  theme: "dark",
  notifications: [],
  // ...
});

// notifications が変わるだけで user しか使っていないコンポーネントも再レンダリングされる
tsx
// ✅ 用途別にコンテキストを分割する
const UserContext = createContext(null);
const ThemeContext = createContext("dark");
const NotificationContext = createContext([]);

// UserContext しか使っていないコンポーネントは
// notifications が変わっても再レンダリングされない

コンテキストはなるべく「変化の頻度が似た値ごと」に分けると、余計な再レンダリングを避けられる。

まとめ

再レンダリングの最適化は「計測 → 特定 → 対処」の順番で進める。

ツール役割使いどころ
React.memopropsが変わらなければ再レンダリングをスキップ重いコンポーネント、不要な再レンダリングが確認できたもの
useMemo計算結果 or オブジェクト参照をメモ化重い計算、memoコンポーネントに渡すオブジェクト
useCallback関数参照をメモ化memoコンポーネントに渡す関数、useEffectの依存関数

「とりあえず memo を貼る」は逆効果になることもある。まず React DevTools Profiler で実際に不要な再レンダリングが起きているかを確認して、原因が特定できてから対処するのが正しい順番だ。

最適化の優先順位:

  1. 計測して問題を確認する
  2. コンポーネントの設計(コロケーション)で解決できないかを考える
  3. memouseCallbackuseMemo の順で対処する