⚛️ React Boundary
基礎 — レンダリング

🔁 再レンダリングはいつ起こるのか?

「なぜこのコンポーネントが再レンダリングされているのか?」は、Reactのパフォーマンス問題を解決する上で最も重要な問いです。 再レンダリングのすべてのトリガーと伝播ルールを体系的に解説します。

🤔 問題提起

Reactアプリを開発していると、次のような疑問や問題に直面することがあります。

  • 「このコンポーネント、何もしていないのになぜ毎回再レンダリングされているのか?」
  • 「stateを更新したら、関係のないコンポーネントまで再レンダリングされた」
  • 「propsを変えていないのに、子コンポーネントが再レンダリングされる理由がわからない」
  • 「React.memoを使っているのに、なぜ再レンダリングが起きるのか?」

これらはすべて「再レンダリングのトリガーと伝播の仕組み」を正確に理解することで解決できます。 Reactのレンダリングモデルはシンプルですが、正確に理解していないと不可解な動作に見えます。

🎯 結論から言う

再レンダリングのトリガーは3つだけです。

1 stateの更新 — useState/useReducerのdispatch、クラスのsetState
2 親コンポーネントの再レンダリング — propsが変わっていなくても伝播する
3 contextの変更 — useContextで購読しているコンテキスト値が変わった

🗺️ 再レンダリングの全体フロー

トリガーからDOM更新までのフロー
トリガー発生
setState() / dispatch() / context値変更 / 親の再レンダリング
↓ Reactスケジューラーがタスクをキューに積む
Render Phase(純粋な計算)
コンポーネント関数を呼び出し → 新しいReactElementツリーを構築 → 差分検出(Reconciliation)
↓ 差分のあったFiberにフラグを付ける
Commit Phase(副作用を実行)
実際のDOMを変更 → useLayoutEffect → ブラウザペイント → useEffect
↓ 画面更新完了
ユーザーが新しいUIを見る
work-in-progress tree が current tree になる

重要:「再レンダリング」とは「コンポーネント関数の再実行」のことです。 これは必ずしもDOMの変更を意味しません。 Reactはrender phaseで差分を計算し、実際に変化があった部分のみDOMを更新します。

① トリガー1: stateの更新

最も基本的なトリガーです。useStateのsetter関数や useReducerのdispatchを呼び出すと、そのコンポーネントが再レンダリングされます。

stateの更新と再レンダリング
function Counter() {
  const [count, setCount] = useState(0);

  console.log('Counter レンダリング'); // 毎回実行される

  return (
    <div>
      <p>{count}</p>
      {/* setCountを呼ぶ → Counterが再レンダリングされる */}
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}

React 18 Automatic Batching

複数のstate更新を連続して呼び出した場合、Reactは賢くそれらをバッチ処理します。 つまり、1回のイベントハンドラー内で複数のsetStateを呼んでも、再レンダリングは1回だけです。

function Form() {
  const [name, setName] = useState('');
  const [age, setAge] = useState(0);
  const [email, setEmail] = useState('');

  function handleSubmit() {
    // React 18以降: これら3つの更新がバッチ処理される
    // → 再レンダリングは1回だけ
    setName('Alice');
    setAge(30);
    setEmail('alice@example.com');
  }

  // React 17以前は: setTimeout、Promiseの中での更新はバッチされなかった
  // React 18以降: すべての更新が自動的にバッチされる(Automatic Batching)
  async function fetchAndUpdate() {
    const data = await fetch('/api/user').then(r => r.json());
    // React 17以前: これぞれ別々に再レンダリングが起きた
    // React 18以降: 1回の再レンダリングにまとめられる ✅
    setName(data.name);
    setAge(data.age);
  }
}

同じ値でのsetState — 再レンダリングされない

ReactはObject.is()で現在の値と新しい値を比較します。 同じ値(Object.isがtrueを返す)の場合、再レンダリングはスキップされます。

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

// ✅ 同じ値 → 再レンダリングされない(Object.is(0, 0) === true)
setCount(0);

// ✅ 同じ参照のオブジェクト → 再レンダリングされない
const obj = { x: 1 };
const [state, setState] = useState(obj);
setState(obj); // 同じ参照

// ⚠️ 新しいオブジェクト → 再レンダリングされる(参照が異なる)
setState({ x: 1 }); // Object.is({ x:1 }, { x:1 }) === false

// ⚠️ 配列も同様
const [arr, setArr] = useState([1, 2, 3]);
setArr([1, 2, 3]); // 新しい配列 → 再レンダリングされる

② トリガー2: 親コンポーネントの再レンダリング

最も見落とされがちなトリガーです。 親コンポーネントが再レンダリングされると、その子コンポーネントもpropsが変わっていなくても再レンダリングされます。

よくある誤解:「propsが変わらなければ再レンダリングされない」

これは間違いです。Reactのデフォルト動作では、親が再レンダリングされると、 子コンポーネントはReact.memoでラップされていない限り必ず再レンダリングされます。

function Parent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>カウント: {count}</button>
      {/* Childはcountを受け取らない。propsは変わらない。 */}
      {/* それでも、Parentが再レンダリングされるたびにChildも再レンダリングされる! */}
      <Child />
    </div>
  );
}

function Child() {
  console.log('Child レンダリング'); // Parentのボタンを押すたびに実行される
  return <p>私は子コンポーネントです</p>;
}
再レンダリングの伝播パターン

以下のようなコンポーネントツリーを考えます。 Bでstateが更新された場合の伝播を見てみましょう。

App
├── Header(再レンダリングなし)
└── Main
├── B(← stateが更新された)
├── C(再レンダリングされる)
└── D(再レンダリングされる)
└── E(再レンダリングされる)
└── F(再レンダリングなし)
  • Bのstateが更新される
  • Bが再レンダリングされる
  • Bの子C、E(とその子孫D)も再レンダリングされる
  • Bの兄弟FはBの子孫ではないので再レンダリングされない
  • BやMainの親(App、Main)は上方向には伝播しない

React.memoによる伝播のブロック

子コンポーネントをReact.memoでラップすると、 propsをObject.isで比較し、変わっていなければ再レンダリングをスキップします。

// React.memoでラップ → propsが変わらなければスキップ
const Child = React.memo(function Child({ name }) {
  console.log('Child レンダリング');
  return <p>Hello, {name}!</p>;
});

function Parent() {
  const [count, setCount] = useState(0);
  // countが変わってもnameは変わらない → Childはスキップされる ✅
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>カウント: {count}</button>
      <Child name="Alice" />
    </div>
  );
}

// ⚠️ ただしオブジェクト・配列・関数はメモ化が必要
// 毎レンダリングで新しい参照が生まれると、React.memoは効果なし
function BadParent() {
  const [count, setCount] = useState(0);
  const style = { color: 'red' }; // ← 毎回新しいオブジェクト
  const onClick = () => {};       // ← 毎回新しい関数
  return <MemoChild style={style} onClick={onClick} />;  // ← memoが意味をなさない
}

③ トリガー3: contextの変更

useContext(MyContext)を呼んでいるコンポーネントは、 そのコンテキストの値が変更されると再レンダリングされます。 これはコンポーネントツリーのどこに位置していても適用されます。

contextの変更による再レンダリング
const ThemeContext = createContext('light');

function App() {
  const [theme, setTheme] = useState('light');

  return (
    // valueが変わると、useContext(ThemeContext)を使う
    // すべてのコンポーネントが再レンダリングされる
    <ThemeContext.Provider value={theme}>
      <Layout>
        <Header />  {/* useContextを使っていれば再レンダリング */}
        <Main />    {/* useContextを使っていれば再レンダリング */}
      </Layout>
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        テーマ切替
      </button>
    </ThemeContext.Provider>
  );
}

// このコンポーネントは ThemeContext を購読している
function Header() {
  const theme = useContext(ThemeContext);
  // themeが変わるたびにHeaderは再レンダリングされる
  return <header className={theme}>...</header>;
}

// useContextを呼んでいないコンポーネントは再レンダリングされない
function Footer() {
  return <footer>© 2024</footer>;  // ← contextが変わっても影響なし
}

⚠️ contextのパフォーマンスに関する注意

コンテキストの値にオブジェクトを使う場合、プロバイダーの親コンポーネントが再レンダリングされるたびに 新しいオブジェクト参照が生成され、すべてのConsumerが不必要に再レンダリングされます。

// ⚠️ 問題のあるパターン
function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  // AppProviderが再レンダリングされるたびに新しいオブジェクトが生成される
  // → すべてのuseContext(AppContext)が再レンダリングされる
  return (
    <AppContext.Provider value={{ user, theme, setUser, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

// ✅ 改善: useMemoでオブジェクトをメモ化
function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  const value = useMemo(
    () => ({ user, theme, setUser, setTheme }),
    [user, theme]  // user か theme が変わったときだけ新しいオブジェクト
  );

  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}

contextを分割する戦略

変更頻度の異なる値を同じコンテキストに入れると、一部の値が変わるだけで全Consumerが更新されます。 変更頻度ごとにコンテキストを分割するのが効果的です。

// ✅ 変更頻度ごとにコンテキストを分割
const UserContext = createContext(null);       // ほとんど変わらない
const ThemeContext = createContext('light');   // テーマ切替時のみ
const CartContext = createContext([]);         // カートに追加/削除時

// UserContextだけを使うコンポーネントは
// ThemeやCartが変わっても再レンダリングされない

🔄 forceUpdateと強制再レンダリング

stateが変わっていなくても再レンダリングを強制したい場合があります。 クラスコンポーネントにはforceUpdate()が存在しますが、 関数コンポーネントでは別の方法を使います。

強制再レンダリングのパターン
// クラスコンポーネント: forceUpdate()
class MyClass extends React.Component {
  handleSomething = () => {
    // stateを変えずに再レンダリングを強制
    this.forceUpdate();
  };
}

// 関数コンポーネント: カウンターを使ったハック
function useForceUpdate() {
  const [, dispatch] = useReducer(x => x + 1, 0);
  return dispatch;
}

function MyComponent() {
  const forceUpdate = useForceUpdate();

  // 外部の可変オブジェクトを監視するときなどに使う
  function handleChange() {
    externalStore.update();
    forceUpdate(); // ← 強制的に再レンダリング
  }
}

// React 18以降の推奨: useSyncExternalStore
// 外部ストアとの同期には専用のAPIが提供されている
const snapshot = useSyncExternalStore(
  store.subscribe,
  store.getSnapshot
);

⚠️ 注意:forceUpdateや強制再レンダリングは通常のReactのデータフローを壊します。 本当に必要な場合以外は使用を避け、可能な限りstateで状態を管理してください。

🛡️ React.memoと比較のしくみ

React.memoは高階コンポーネント(HOC)で、 propsをObject.isで浅い比較(shallow comparison)し、変化がなければ再レンダリングをスキップします。

Object.isによる比較ルール
// Object.isの比較ルール
Object.is(1, 1)           // true  → 再レンダリングなし
Object.is('a', 'a')       // true  → 再レンダリングなし
Object.is(true, true)     // true  → 再レンダリングなし
Object.is(null, null)     // true  → 再レンダリングなし
Object.is(undefined, undefined) // true → 再レンダリングなし

Object.is({}, {})         // false → 再レンダリングあり(新しい参照)
Object.is([], [])         // false → 再レンダリングあり(新しい参照)
Object.is(() => {}, () => {}) // false → 再レンダリングあり(新しい参照)

// ⚠️ NaNの特殊ケース(==やObject.isの違い)
NaN === NaN               // false(通常の===演算子)
Object.is(NaN, NaN)       // true(Object.isは正しくNaNを扱う)

カスタム比較関数

React.memoの第2引数にカスタム比較関数を渡すことができます。 深い比較が必要な場合や、特定のpropsのみを比較したい場合に使います。

const UserCard = React.memo(
  function UserCard({ user, onEdit }) {
    return <div>{user.name}</div>;
  },
  // カスタム比較関数(trueを返すと再レンダリングをスキップ)
  (prevProps, nextProps) => {
    // IDと名前だけを比較(他のフィールドは無視)
    return (
      prevProps.user.id === nextProps.user.id &&
      prevProps.user.name === nextProps.user.name
      // onEditは比較しない(関数は毎回変わるため)
    );
  }
);

useMemoとuseCallbackの役割

React.memoが効果を発揮するためには、オブジェクト・配列・関数のpropsが安定した参照を持つ必要があります。 useMemouseCallbackはそのために使います。

function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Alice');

  // ✅ useCallbackで関数を安定化
  const handleEdit = useCallback((id) => {
    console.log('edit', id);
  }, []); // 依存配列が空 → 常に同じ関数参照

  // ✅ useMemoでオブジェクトを安定化
  const config = useMemo(() => ({
    maxItems: 100,
    sortBy: 'name'
  }), []); // 依存配列が空 → 常に同じオブジェクト参照

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      {/* countが変わってもMemoChildは再レンダリングされない ✅ */}
      <MemoChild onEdit={handleEdit} config={config} />
    </>
  );
}

🔍 再レンダリング伝播の実例:完全なシナリオ

実際のアプリに近い構成で、どのコンポーネントが再レンダリングされるかを詳しく見てみましょう。

ショッピングカートアプリの再レンダリング分析
// コンポーネントツリー
// App
//   ├── Header(カート件数を表示)← CartContextを使用
//   ├── ProductList
//   │   ├── ProductCard × N   ← React.memoでラップ済み
//   └── CartSidebar           ← CartContextを使用
//       └── CartItem × N      ← React.memoでラップ済み

const CartContext = createContext({ items: [], addItem: () => {} });

function App() {
  const [cartItems, setCartItems] = useState([]);
  const [searchQuery, setSearchQuery] = useState('');

  const addItem = useCallback((item) => {
    setCartItems(prev => [...prev, item]);
  }, []);

  const cartValue = useMemo(
    () => ({ items: cartItems, addItem }),
    [cartItems, addItem]
  );

  // searchQueryが変わったとき:
  // → App が再レンダリング
  // → Header: CartContextが変わっていない → useMemoにより同じvalue → 再レンダリなし
  // → ProductList: 再レンダリング(Appの子)
  // → ProductCard: React.memoによりpropsが変わらなければスキップ
  // → CartSidebar: CartContextが変わっていない → 再レンダリなし

  // addItem(カートに追加)が呼ばれたとき:
  // → cartItemsが変わる → App が再レンダリング
  // → cartValue が変わる(useMemoの依存変更)
  // → Header: CartContextが変わった → 再レンダリング
  // → CartSidebar: CartContextが変わった → 再レンダリング
  // → ProductList: 再レンダリング(Appの子)
  // → ProductCard: React.memoによりpropsが変わらなければスキップ

  return (
    <CartContext.Provider value={cartValue}>
      <Header />
      <input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
      <ProductList query={searchQuery} />
      <CartSidebar />
    </CartContext.Provider>
  );
}

📌 まとめ

  • ✓ 再レンダリングのトリガーは「state更新」「親の再レンダリング」「context変更」の3つのみ
  • ✓ 「再レンダリング」= 「コンポーネント関数の再実行」。DOM変更とは別の話
  • ✓ 親が再レンダリングされると子も再レンダリングされる——propsが変わらなくても
  • ✓ React 18のAutomatic Batchingで複数のsetState呼び出しは1回の再レンダリングにまとめられる
  • ✓ Object.isで現在値と新値が等しければstateの更新でも再レンダリングはスキップされる
  • ✓ React.memoはpropsのObject.is浅い比較で再レンダリングをスキップ
  • ✓ useCallbackとuseMemoで関数・オブジェクトの参照を安定させてReact.memoを有効にする
  • ✓ contextは変更頻度ごとに分割してConsumerの不要な再レンダリングを防ぐ

関連記事