2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Reactのパフォーマンス最適化とカスタムフックを学び直して腑に落ちたこと

2
Posted at

Reactのパフォーマンス最適化とカスタムフックを学び直して腑に落ちたこと

はじめに

普段はLaravel/PHPをメインで書いていて、フロントはReactを触るという立場です。
これまで「とりあえず useCallback で包んでおけば速くなる」くらいの雑な理解でReactを書いていたのですが、パフォーマンス最適化とカスタムフックをちゃんと学び直したところ、そもそも前提を勘違いしていたことに気づいたのでまとめます。

同じように「メモ化、なんとなくで書いてる」という人の役に立てば嬉しいです。


結論(先に)

学んだ結果、自分の中で整理された結論は3つです。

  1. メモ化は「速くする技術」ではなく「再レンダリングをスキップする技術」。遅い原因が別のところにあるなら効果はゼロ、むしろ遅くなる
  2. メモ化より先に「設計で解決できないか」を疑う。stateを下げる/childrenで切り離すだけで消える問題が多い
  3. カスタムフックは「UIを持たないロジックの再利用単位」。コンポーネントを短くするための切り出しではない

そして2026年現在、React Compilerが安定版になったことで手動メモ化の大半は不要になりつつあります。ただし「仕組みを理解しないままコンパイラに任せる」のは危険なので、原理は押さえておきたいところ。


1. そもそもなぜ再レンダリングが起きるのか

ここの理解が一番あやふやでした。Reactの再レンダリングのトリガーはシンプルで、

  • 自分の state が変わった
  • 自分が購読している context の値が変わった
  • 親が再レンダリングされた

の3つです。3つ目が重要で、propsが変わっていなくても、親が再レンダリングされれば子も再レンダリングされる

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

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      {/* propsは何も渡していないのに、countが変わるたびに再レンダリングされる */}
      <HeavyList />
    </div>
  );
}

「propsが変わったら再レンダリング」だと思い込んでいたので、ここが最初のつまずきポイントでした。

再レンダリング≠DOM更新

もう一つ勘違いしていたのが、再レンダリング=画面の描き直しだと思っていたこと。実際には、

  1. コンポーネント関数を実行してVDOMを作る(= レンダリング)
  2. 前回のVDOMと差分を取る
  3. 差分があった箇所だけ実DOMに反映(= コミット)

なので、再レンダリングが起きても実DOM更新は0件、ということが普通にあります
つまり「再レンダリングされている=悪」ではない。関数の実行コストが軽ければ、放っておいて問題ないケースがほとんどです。

これはLaravelでいうとN+1問題に近い感覚で、「クエリが100回飛んでるからヤバい」ではなく「その100回で実際に何秒かかっているか」を見ないと意味がない、というのと同じだと理解しました。


2. 最適化の前に測る

学ぶ前の自分は、体感で遅いところにいきなり memo を貼っていました。これは完全に順序が逆でした。

React DevTools の Profiler タブで記録すると、

  • どのコンポーネントが
  • 何回レンダリングされ
  • 1回あたり何ms かかったか

が出ます。「Why did this render?」を有効にすると再レンダリングの理由まで出るので、これを見ないと手の打ちようがない。

さらに、⚙️ → Highlight updates when components render をONにすると、再レンダリングされたコンポーネントが画面上で光ります。これが一番直感的で、「タイピングするたびに画面全体が光る」みたいな異常にすぐ気づけます。

測る前にメモ化するのは、EXPLAINを見ずにインデックスを貼るのと同じ

これが個人的に一番刺さった学びでした。


3. React.memo の落とし穴

React.memo は「propsが変わっていなければ再レンダリングをスキップする」HOCです。

const HeavyList = memo(function HeavyList({ items }) {
  return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
});

ただし比較は浅い比較(Object.is)。ここで盛大にハマりました。

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

  // ❌ 毎回新しい配列・新しい関数が生成されるので memo が完全に無意味
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <HeavyList items={[{ id: 1, name: 'a' }]} onSelect={() => {}} />
    </>
  );
}

itemsonSelect も毎回別の参照になるので、memo を付けていても毎回再レンダリングされます。しかも比較コストが増えるぶん、付けないより遅い。

React.memo は「渡すpropsの参照が安定していること」とセットで初めて効く。ここを理解していなかったのが、これまで効果を感じられなかった原因でした。


4. useMemo / useCallback は何のためにあるのか

そしてこの「参照を安定させる」のが useMemo / useCallback の本来の役割です。

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

  // 参照が安定する → memo された子が再レンダリングされない
  const handleSelect = useCallback((id) => {
    setItems(prev => prev.filter(i => i.id !== id));
  }, []);

  const sorted = useMemo(
    () => [...items].sort((a, b) => a.name.localeCompare(b.name)),
    [items]
  );

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <HeavyList items={sorted} onSelect={handleSelect} />
    </>
  );
}

用途は結局この3つに集約されると理解しました。

用途 使うもの
重い計算結果をキャッシュしたい useMemo
memo した子に渡すprops(オブジェクト/配列/関数)の参照を安定させたい useMemo / useCallback
useEffect の依存配列に入る値を安定させたい useMemo / useCallback

逆に言うと、この3つに当てはまらないメモ化は基本的に不要。メモ化にもコスト(依存配列の比較、キャッシュの保持)があるので、無条件に付けるのは正しくない。

useCallback は「関数をメモ化」しているわけではない

地味に大事だったのがこれ。useCallback(fn, deps)useMemo(() => fn, deps) と同義で、関数の中身をキャッシュしているのではなく、関数オブジェクトの参照を保持しているだけです。関数の実行が速くなるわけではない。


5. メモ化する前に、設計で解決できないか疑う

ここが学んで一番価値があったところです。メモ化を足すより、構造を変えたほうがきれいに解決するケースが多い。

パターンA: stateを下げる(lift state down)

// ❌ inputのstateが親にあるので、1文字打つたびにHeavyListも再レンダリング
function Page() {
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <HeavyList />
    </>
  );
}

// ✅ stateを必要な範囲だけに閉じ込める
function SearchInput() {
  const [text, setText] = useState('');
  return <input value={text} onChange={e => setText(e.target.value)} />;
}

function Page() {
  return (
    <>
      <SearchInput />
      <HeavyList />   {/* もう再レンダリングされない */}
    </>
  );
}

memo を一切使わずに解決できました。

パターンB: children として渡して切り離す

stateを下げられない(親のstateを他でも使う)場合はこれ。

// ✅ childrenは親の外で作られた要素なので、Wrapperが再レンダリングされても再生成されない
function Wrapper({ children }) {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      {children}
    </div>
  );
}

function Page() {
  return (
    <Wrapper>
      <HeavyList />   {/* Wrapperのstateが変わっても再レンダリングされない */}
    </Wrapper>
  );
}

children に渡した要素は Page のレンダリング時に生成され、Wrapper の再レンダリングでは作り直されません。この「Reactの仕組みそのものを使った最適化」は、知らないと絶対に思いつかないやつでした。


6. React Compilerを踏まえて

ここまで手動メモ化の話を書いてきましたが、React Compilerの登場で前提が変わりつつあります。

React Compilerはビルド時にReactコードを解析して適切な場所に自動でメモ化を挿入するツールで、2025年10月にv1.0の安定版がリリースされ、React 17以上で利用可能になりました。Metaの本番環境でも実績があり、初期ロードが最大12%改善、特定のインタラクションが2.5倍以上高速化したという報告があります。

つまり useMemo / useCallback / React.memo を手で書く必要は、原則としてなくなる方向です。

ただ、学んでみて思ったのは「だから勉強しなくていい」ではないということ。

  • コンパイラはReactのルールを守ったコードしか最適化しない(レンダリング中の副作用、propsのミューテーションなどがあるとスキップされる)
  • 期待通りメモ化されているかを確認するには、結局「なぜここでメモ化が必要か」を理解している必要がある
  • 公式にはマイナー/パッチバージョンでもメモ化の挙動が変わる可能性があるとされており、自動アップグレードではなく手動で確認しながら更新することが推奨されています

「手で書かなくてよくなった」だけで、「理解しなくてよくなった」わけではない、という整理に落ち着きました。


7. カスタムフック: 何を切り出すべきか

後半はカスタムフックの話。ここも認識を改めた部分が大きかったです。

以前は「コンポーネントが長くなったから切り出す」という発想で作っていましたが、これは間違い。カスタムフックの本質は「stateとライフサイクルを持つロジックを、UIから独立させて再利用する」ことです。

判断基準はシンプルで、

  • ✅ 中で useState / useEffect / useRef などのフックを使っている → カスタムフック
  • ❌ 単なる計算・変換だけ → ただの関数でいい
// ❌ フックを使っていないのにカスタムフックにしている
function useFormatPrice(price) {
  return ${price.toLocaleString()}`;
}

// ✅ ただの関数でいい(テストも楽、どこでも呼べる)
export function formatPrice(price) {
  return ${price.toLocaleString()}`;
}

use を付けるかどうか」は、フックのルール(トップレベルでしか呼べない、条件分岐の中で呼べない)という制約を背負うかどうかの宣言でもある。制約が要らないなら普通の関数にすべき、というのは言われてみれば当たり前でした。


8. 実際に作って便利だったカスタムフック

useDebounce — 検索インプットの定番

import { useState, useEffect } from 'react';

export function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);   // ← クリーンアップが肝
  }, [value, delay]);

  return debounced;
}
function SearchBox() {
  const [text, setText] = useState('');
  const debouncedText = useDebounce(text, 500);

  useEffect(() => {
    if (!debouncedText) return;
    fetchResults(debouncedText);
  }, [debouncedText]);

  return <input value={text} onChange={e => setText(e.target.value)} />;
}

setTimeout をクリーンアップで潰す、という発想が理解できてから、useEffect の返り値の意味がようやく腑に落ちました。

useToggle — 小さいけど効く

import { useState, useCallback } from 'react';

export function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(v => !v), []);
  return [value, toggle, setValue];
}

setValue(v => !v) という関数型アップデートにすることで、依存配列を空にできる。これも学ぶまで理解していませんでした。setValue(!value) と書くと value を依存に入れる必要があり、毎回参照が変わってしまう。

useEventCallback — 最新の値を見つつ参照を固定する

useCallback の依存配列に何か入れると参照が変わってしまうが、コールバックの中では最新の値を見たい、というジレンマ用。

import { useRef, useCallback, useLayoutEffect } from 'react';

export function useEventCallback(fn) {
  const ref = useRef(fn);

  useLayoutEffect(() => {
    ref.current = fn;    // 毎回最新の関数に差し替える
  });

  // 返す関数の参照は永久に不変
  return useCallback((...args) => ref.current(...args), []);
}

イベントハンドラを memo した子に渡すときに効きます。
なおReactにはこれを言語機能として取り込む useEffectEvent が実験的に用意されているので、将来的にはそちらに置き換わりそうです(実験段階なので本番導入は要検討)。


9. カスタムフックでやりがちなアンチパターン

学びながら「自分これやってた…」となったものを挙げておきます。

① とりあえず全部フックに押し込む

// ❌ ページの全ロジックが1つのフックに詰め込まれている
function useUserPage() {
  // user取得、フォーム管理、モーダル制御、ページネーション、全部入り
}

再利用できない・テストできない・依存が複雑になる、で完全に逆効果。1フック1責務が原則。

② フック内のstateは共有されない

function A() { const { data } = useUser(); }  // fetchが走る
function B() { const { data } = useUser(); }  // もう1回fetchが走る

カスタムフックはロジックの再利用であって、stateの共有ではない。ここを勘違いすると「なぜか同じAPIが2回叩かれる」という事故になります。共有したいならContextやJotai等の状態管理、サーバー状態ならTanStack Queryの出番。

③ データフェッチを自前フックで頑張る

useFetch を自作するのは学習としては最高ですが、実務でやるとキャッシュ・リトライ・重複排除・楽観的更新…と際限なく必要になります。ここは素直にTanStack Queryなどに任せるのが正解、という結論になりました。


まとめ

学び直して、自分の中の優先順位はこう変わりました。

  1. まず測る(Profiler / Highlight updates)
  2. 設計で消せないか考える(stateを下げる、childrenで切り離す)
  3. それでもダメならメモ化する(memo + useMemo/useCallback はセット)
  4. React Compilerを入れるなら3は任せる。ただし原理は理解しておく
  5. カスタムフックはフックを使うロジックの再利用単位。UIの整理整頓のために作らない

「なんとなく useCallback」から「なぜここで必要か説明できる」に変わっただけでも、コードレビューの質がだいぶ変わった実感があります。

同じところで詰まっている人の参考になれば。間違いや補足があればコメントで指摘いただけると嬉しいです。


参考

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?