32blogby Studio Mitsu

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

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

by omitsu19 min read
ReactperformancememouseMemouseCallbackre-render
目次

Reactの不要な再レンダリングを防ぐには、propsが変わっていないときに再レンダリングをスキップする React.memo、オブジェクト参照の安定化と重い計算のキャッシュに useMemo、関数参照の安定化に useCallback を使う。 最適化の前に必ずReact DevTools Profilerで計測すること。

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

数百件のリストがあるページでスクロールの引っかかりを感じたら、React DevTools Profilerを開いてみてほしい。変更と関係ないコンポーネントまで毎回フラッシュしていたら、それが最適化のサインだ。

この記事では、再レンダリングが起きる仕組みを理解したうえで、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が変わっていない場合にコンポーネントの再レンダリングをスキップできる。propsの比較には Object.is が使われる。

❌ 最適化前

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 Compilerはどうなの?

React Compiler(現在ベータ版)は、ビルド時にコードを解析して必要な箇所に自動でメモ化を挿入してくれるツールだ。memouseMemouseCallback を手動で書く手間を省いてくれる。

ではもうメモ化を学ぶ必要はないのか? そうとも言い切れない:

  • Compilerはopt-inで、まだベータ版だ。 多くのプロダクション環境ではまだ使われていない
  • 再レンダリングが起きる仕組みの理解 は、コンポーネント設計(コンテキスト分割、state配置)に直結する — Compilerでは解決できない領域だ
  • カスタム比較関数やアーキテクチャレベルの判断 は、引き続き開発者の仕事

Compilerはセーフティネットであって、理解の代替ではない。この記事のパターンが基礎であり、Compilerはその上に乗るものだ。

FAQ

React.memoは全コンポーネントに貼るべき?

貼るべきではない。memo は毎レンダリングでpropsの比較コストが発生する。シンプルで軽いコンポーネントでは、このオーバーヘッドが節約分を上回ることもある。DevToolsで不要な再レンダリングを確認した「重いコンポーネント」や「リストアイテム」に絞って使おう。

useMemoとuseCallbackの違いは?

useCallback(fn, deps)useMemo(() => fn, deps) のショートハンドだ。useMemo(オブジェクト含む)をキャッシュし、useCallback関数参照 をキャッシュする。memo化された子コンポーネントに関数を渡すときは useCallback、重い計算やオブジェクト参照の安定化には useMemo を使う。

React Compilerがあればmemo/useMemo/useCallbackは不要になる?

React Compiler はビルド時にメモ化を自動挿入するので、多くの場面で手動のフック記述が不要になる。ただし、まだベータ版でopt-inだ。基本を理解しておけば、Compilerが最適化しやすいコードを書けるようになる。

memo化したコンポーネントがまだ再レンダリングされる理由は?

最も多い原因は、propsに毎回新しいオブジェクトや関数の参照を渡していること。memoObject.is で比較するので、{ name: "太郎" } !== { name: "太郎" } になる(参照が異なるため)。useMemo(オブジェクト)や useCallback(関数)で参照を安定させよう。

memoはchildren propsでも効く?

うまく効かない。<Wrapper><Child /></Wrapper> のようなJSXのchildrenは毎レンダリングで新しいReact要素参照を生成するため、memo が無効化される。children-as-propsパターンを使うか、コンポーネントツリーを再構成しよう。

React Server Componentsでもmemoは使える?

Server Components はサーバー上で実行されてクライアント側では再レンダリングされないため、memo は不要だ。memo は再レンダリング問題が確認されたClient Components('use client')にのみ使う。

再レンダリングのパフォーマンスはどう計測する?

React DevToolsの Profiler を使う。「Highlight updates when components render」をオンにすれば再レンダリングが視覚的に確認でき、フレームグラフでどのコンポーネントに時間がかかっているかを特定できる。Chrome DevToolsのPerformanceタブで、過度な再レンダリングによるLong Taskも確認できる。

まとめ

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

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

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

最適化の優先順位:

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

関連記事: