Back

Reactアプリの再レンダリングを80%減らした方法

Reactアプリの再レンダリングを80%減らした方法

きれいなReactアプリを作った。コードもきれい、コンポーネント設計もバッチリ、ちゃんと動く。でも何かおかしい。フォームの入力がもたつく。リストのスクロールがカクつく。モーダルを開くのに一瞬かかる。アプリが...重い。

React DevTools Profilerを開いてみたら衝撃だった。1文字入力しただけでコンポーネントが47回も再レンダリングしてる。ボタン1回クリックで200以上のコンポーネントが更新される。state1つ変えるたびにアプリ全体が光る。

再レンダリング問題だ。そしてこれ、みんな経験してる。

Reactアプリで一番多いパフォーマンス問題なのに、一番誤解されてる問題でもある。多くの開発者がReact.memouseMemouseCallbackをとりあえずバラまく。何か効くことを願って。でも大体それで逆に遅くなる。

この記事では、なぜ再レンダリングが起きるのかをちゃんと理解して、パフォーマンスを食ってるパターンを見つけて、実際にレンダリング回数を80%減らした方法を紹介する。原理を理解して、ピンポイントで直すやり方だ。

再レンダリングはいつ起きる?

最適化の前に、まず再レンダリングのタイミングを理解しよう。ルールはシンプル:

1. stateが変わると再レンダリング

useStateuseReducerでstateが変わると、そのコンポーネントが再レンダリングされる:

function Counter() { const [count, setCount] = useState(0); // クリックするたびにCounterが再レンダリング return <button onClick={() => setCount(c => c + 1)}>{count}</button>; }

これは当然だし必要なこと。最適化の必要なし。

2. 親が再レンダリングすると子も道連れ

親コンポーネントが再レンダリングすると、propsが変わってなくても子も全部再レンダリングされる

function Parent() { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount(c => c + 1)}>Count: {count}</button> {/* ExpensiveChildはcountと関係ないのに */} {/* countが変わるたびに一緒に再レンダリングされる */} <ExpensiveChild /> </div> ); } function ExpensiveChild() { // 親が再レンダリングするたびにここも実行される console.log('ExpensiveChild rendered'); return <div>レンダリングコストが高いコンポーネント</div>; }

パフォーマンス問題の90%がここから来てる。親にstateがあるから再レンダリングするんだけど、子はそのstateを使ってない。でもReactはそれを知らないから一緒に再レンダリングさせる。

3. Contextが変わると購読者全員再レンダリング

useContext(SomeContext)を使ってるコンポーネントは、そのcontextの値が変わると全部再レンダリングされる:

const ThemeContext = createContext({ theme: 'light' }); function App() { const [theme, setTheme] = useState('light'); const [user, setUser] = useState(null); // 問題:userだけ変えたのにthemeを使ってる奴も再レンダリング // valueオブジェクト自体が新しく作られるから return ( <ThemeContext.Provider value={{ theme, user, setUser }}> <ThemedButton /> {/* userが変わると再レンダリング! */} </ThemeContext.Provider> ); }

二番目に多いパフォーマンス問題の原因。contextの値が頻繁に変わりすぎるか、詰め込みすぎてる時に起きる。

問題を見つける:React DevTools Profiler

何かを直す前にデータが必要。React DevToolsを開いてProfilerタブへ。

ステップ1:遅い操作を録画

「Start profiling」を押して、遅いと感じる操作をやってみる。タイピング、スクロール、モーダルの開閉とか。その後プロファイリングを止める。

ステップ2:グラフを分析

Flamegraphで見えるもの:

  • どのコンポーネントがレンダリングされたか(色付きのバー)
  • どれくらい時間がかかったか(バーの幅)
  • なぜレンダリングされたか(マウスオーバーで詳細が出る)

こういうのを探す:

  1. 変わるべきじゃないのに変わったコンポーネント(グレーであるべきなのに黄色)
  2. 同じコンポーネントが何度もレンダリング(繰り返されるバー)
  3. 重いコンポーネントが頻繁にレンダリング(幅広いバーが何度も出てくる)

ステップ3:レンダリングハイライトを有効化

React DevToolsの設定で「Highlight updates when components render」をオンにする。そしてアプリを操作してみる。再レンダリングされるコンポーネントが光る。1文字タイプしただけでアプリ全体が光るなら、問題を見つけた。

よくあるミス(と直し方)

ミス1:レンダリングのたびに新しいオブジェクトを作る

一番多いミス。レンダリング中に新しいオブジェクトや配列を作ると、子は毎回「新しい」propsを受け取る:

// ❌ ダメ:レンダリングのたびに新しい配列、新しいオブジェクト function TodoList({ todos }) { return ( <List items={todos.filter(t => !t.completed)} // 毎回新しい配列 config={{ showDates: true }} // 毎回新しいオブジェクト /> ); } // ✅ OK:参照を維持する function TodoList({ todos }) { const activeTodos = useMemo( () => todos.filter(t => !t.completed), [todos] ); const config = useMemo( () => ({ showDates: true }), [] // 空配列 = 絶対変わらない ); return <List items={activeTodos} config={config} />; }

configが本当に変わらないなら、コンポーネントの外に出す:

// ベスト:外に出す const LIST_CONFIG = { showDates: true }; function TodoList({ todos }) { const activeTodos = useMemo( () => todos.filter(t => !t.completed), [todos] ); return <List items={activeTodos} config={LIST_CONFIG} />; }

ミス2:関数をインラインで渡す

関数をインラインで渡すと、レンダリングのたびに新しい関数ができる:

// ❌ ダメ:レンダリングごとに新しい関数 function TodoItem({ todo, onToggle }) { return ( <Checkbox checked={todo.completed} onChange={() => onToggle(todo.id)} // 毎回新しい関数 /> ); } // ✅ OK:関数の参照を維持 function TodoItem({ todo, onToggle }) { const handleToggle = useCallback( () => onToggle(todo.id), [todo.id, onToggle] ); return ( <Checkbox checked={todo.completed} onChange={handleToggle} /> ); }

注意: useCallbackは子コンポーネントがReact.memoで包まれてるか、その関数を依存配列で使ってる時だけ意味がある。そうじゃなければ単なるオーバーヘッド。

ミス3:stateを上げすぎる

stateは使う場所のできるだけ近くにあるべき:

// ❌ ダメ:フォームのstateがAppにあると全部再レンダリング function App() { const [formData, setFormData] = useState({ name: '', email: '' }); return ( <div> <Header /> {/* 1文字タイプするたびに再レンダリング */} <Sidebar /> {/* 1文字タイプするたびに再レンダリング */} <Form formData={formData} setFormData={setFormData} /> <Footer /> {/* 1文字タイプするたびに再レンダリング */} </div> ); } // ✅ OK:stateを使う場所に置く function App() { return ( <div> <Header /> <Sidebar /> <Form /> {/* stateはここにある */} <Footer /> </div> ); } function Form() { const [formData, setFormData] = useState({ name: '', email: '' }); // Formとその子だけが再レンダリング return (/* ... */); }

ミス4:Contextのvalueオブジェクトを毎回作り直す

Contextの値は参照で比較される。レンダリングのたびに新しいオブジェクトを作ると、購読者全員が再レンダリングされる:

// ❌ ダメ:レンダリングごとに新しいオブジェクト function AuthProvider({ children }) { const [user, setUser] = useState(null); return ( <AuthContext.Provider value={{ user, setUser, isLoggedIn: !!user }}> {children} </AuthContext.Provider> ); } // ✅ OK:useMemoで包む function AuthProvider({ children }) { const [user, setUser] = useState(null); const value = useMemo( () => ({ user, setUser, isLoggedIn: !!user }), [user] // userが変わった時だけ作り直す ); return ( <AuthContext.Provider value={value}> {children} </AuthContext.Provider> ); }

ミス5:全部を1つのContextに入れる

詰め込みすぎると、1つ変わるだけで全員再レンダリング:

// ❌ ダメ:全部入りcontext const AppContext = createContext({ user: null, theme: 'light', notifications: [], sidebarOpen: false, // ... あと20個 }); // これ使ってるコンポーネントは何か1つ変わるだけで再レンダリング // ✅ OK:変更頻度で分割 const UserContext = createContext(null); // ほとんど変わらない const ThemeContext = createContext('light'); // ほとんど変わらない const NotificationContext = createContext([]); // よく変わる const UIContext = createContext({}); // 操作時に変わる

上級パターン

パターン1:childrenをpropsで渡す

stateを持つ親の中で直接子をレンダリングせず、propsで渡す:

// ❌ ダメ:親のstateが変わると子も再レンダリング function Modal({ isOpen }) { const [position, setPosition] = useState({ x: 0, y: 0 }); if (!isOpen) return null; return ( <div style={{ top: position.y, left: position.x }}> <ExpensiveContent /> {/* ドラッグするたびに再レンダリング */} </div> ); } // ✅ OK:childrenで渡す function Modal({ isOpen, children }) { const [position, setPosition] = useState({ x: 0, y: 0 }); if (!isOpen) return null; return ( <div style={{ top: position.y, left: position.x }}> {children} {/* 参照が安定してるから再レンダリングなし */} </div> ); } // 使い方: <Modal isOpen={isOpen}> <ExpensiveContent /> </Modal>

なぜこれが動くかというと、childrenModalの中ではなく外で作られるから。Modalのstateが変わってもchildrenの参照は同じまま。

パターン2:stateがある部分を切り出す

stateとUIが混ざってるなら、stateがある部分を別コンポーネントに切り出す:

// ❌ ダメ:マウス移動でリスト全体が再レンダリング function ItemList({ items }) { const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); return ( <div onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}> <Cursor position={mousePos} /> {items.map(item => ( <ExpensiveItem key={item.id} item={item} /> {/* マウス動かすたびに再レンダリング! */} ))} </div> ); } // ✅ OK:stateがある部分を分離 function ItemList({ items }) { return ( <div> <CursorTracker /> {/* 自分でstate管理 */} {items.map(item => ( <ExpensiveItem key={item.id} item={item} /> ))} </div> ); } function CursorTracker() { const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); return ( <div onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}> <Cursor position={mousePos} /> </div> ); }

パターン3:Contextの一部だけ購読する

Context全体じゃなくて一部だけ必要なら、状態管理ライブラリを使う:

// 問題:Contextのどこが変わっても全部再レンダリング function UserAvatar() { const { user } = useContext(AppContext); // notificationsが変わっても再レンダリングされる return <img src={user.avatar} />; } // 解決策:セレクターがあるライブラリを使う(Zustand、Jotai、Reduxなど) import { create } from 'zustand'; const useStore = create((set) => ({ user: null, theme: 'light', notifications: [], setUser: (user) => set({ user }), })); function UserAvatar() { // userが変わった時だけ再レンダリング const user = useStore((state) => state.user); return <img src={user?.avatar} />; }

パターン4:長いリストを仮想化

100件以上のリストなら仮想化する:

// ❌ ダメ:1万件全部レンダリング function MessageList({ messages }) { return ( <div className="messages"> {messages.map(msg => ( <Message key={msg.id} message={msg} /> ))} </div> ); } // ✅ OK:見えてる分だけレンダリング import { Virtuoso } from 'react-virtuoso'; function MessageList({ messages }) { return ( <Virtuoso data={messages} itemContent={(index, msg) => <Message message={msg} />} /> ); }

仮想化ライブラリのおすすめ:

  • react-virtuoso:チャット系UIに強い
  • @tanstack/react-virtual:カスタマイズしやすい
  • react-window:軽量で実績あり

最適化しなくていい時

パフォーマンス最適化にはコストがかかる:

  1. コードが複雑になる
  2. デバッグしにくくなる
  3. バグを入れやすい
  4. 時間がかかる

こういう時は最適化しなくていい:

  • コンポーネントのレンダリングが速い(16ms以内)
  • ほとんど再レンダリングしない
  • ユーザーから遅いと言われてない
  • Profilerで問題が見えない

Reactはデフォルトで速い。 Virtual DOMがよく最適化されてる。ほとんどの再レンダリングはコストが安い。問題がはっきりしてる時だけ最適化しよう。

実績:80%のパフォーマンス改善

実際の本番アプリでこれらを適用した:

Before:

  • インタラクションあたり平均847コンポーネントがレンダリング
  • 入力遅延120ms
  • スクロール時にカクつき

やったこと:

  1. フォームstateをフォーム内に移動 (-40% レンダリング)
  2. 巨大なContextを5つに分割 (-25% レンダリング)
  3. 重い計算をuseMemoで包む (-10% レンダリング)
  4. メインリストを仮想化 (-15% レンダリング、カクつき解消)

After:

  • インタラクションあたり平均156コンポーネントがレンダリング(81%減)
  • 入力遅延12ms
  • スムーズな60fpsスクロール

修正に2日、プロファイリングに1日かかった。問題の原因を見つけるのが一番大変だった。

デバッグチェックリスト

パフォーマンス問題にぶつかったらこの順番で確認:

  1. まずProfilerを回す - 本当の問題が何かを確認
  2. 新しいオブジェクト/配列のpropsを確認 - 一番多い原因
  3. Contextの使い方を確認 - 変わりすぎてないか?
  4. stateの位置を確認 - 上げすぎてないか?
  5. リストを確認 - 仮想化なしで数百件レンダリングしてないか?
  6. 直したら再測定 - 本当に良くなった?

まとめ

再レンダリング自体は敵じゃない。無駄な再レンダリングが敵。Reactはデフォルトで速いけど、どの更新が意味あるかは私たちが教えてあげないといけない。

いい最適化はコンポーネントツリーを理解することから来る:

  • stateは使う場所の近くに置く
  • Contextは変更頻度で分ける
  • childrenパターンで更新を隔離
  • useMemo/useCallbackは必要な所だけ
  • 長いリストは仮想化

一番大事なこと:Profilerで確認する。勘に頼らない。

ユーザーはコードがきれいかどうか見えない。アプリが速いかどうかだけ感じる。そしてそれを実現するツールはもう手に入れた。

reactperformanceoptimizationjavascriptfrontendhooksmemousecallbackusememo