【実務・中級編】 遅延初期化(Lazy Initialization) – React実践ガイド

やあ。今日も元気にコード書いてるかい?
コードレビューをしていて、ジュニアからシニアへ駆け上がろうとしている中級エンジニアのコードで一番「あー、もったいないな」「これ、知らないと本番環境で地味に効いてくるやつだな」って思うポイントがあるんだ。

それが今回取り上げる「遅延初期化(Lazy Initialization)」だ。

`useState` の初期値に、何気なく重い計算処理やローカルストレージへのアクセスを直書きしてしまっているケース、君のプロジェクトでも見覚えがないかい?
今回は、なぜそれがパフォーマンスのボトルネックになるのか、そしてReactが裏側でどう動いているのかを、現場のリアルな視点から徹底的に紐解いていこうか。

—

なぜ `useState` にそのまま関数や計算を書くとまずいのか?

まずは敵を知ることから始めよう。よく見かけるのがこんなコードだ。

// ❌ ありがちだけど、実は毎回の再レンダリングで無駄コストが発生する例
const [state, setState] = useState(expensiveCalculation());

「あれ? `useState` の引数って初期値なんだから、初回だけ実行されるんじゃないの?」って思ったかい?
残念ながら、それは大きな誤解なんだ。

JavaScriptの言語仕様として、コンポーネントが再レンダリングされるたびに、関数コンポーネントの本体全体が上から下まで再評価(実行)される。 つまり、`useState(expensiveCalculation())` というコードがある場合、Reactがその初期値を採用するかどうかに関わらず、毎回のレンダーの度に `expensiveCalculation()` という関数自体は必ず呼び出されているんだ。

もしこの関数の中で、

  • 大量の配列のフィルタリングやソート
  • `JSON.parse()` を絡めた `localStorage` へのアクセス
  • 複雑なオブジェクトの生成

などをやっていたらどうなる?
初回だけでなく、親からのPropsの変更や、他の些細なstateの更新でコンポーネントが再レンダリングされるたびに、この重い処理が裏で何回も実行され続けることになる。これはブラウザのメインスレッドを無駄に占有し、UIのフレームレート低下(カクつき)を招く立派なパフォーマンスバグの温床だよ。

—

ブラウザの裏側で何が起きているのか?

ここでReactの内部挙動を少し覗いてみよう。

ReactはFiberというアーキテクチャを使って、コンポーネントの状態(State)をメモリ上に保持している。初回レンダリング(Mount)時、Reactは `useState` に渡された値を確認し、「お、まだこのフックのメモリスロットには何も入っていないな」ということで初期値を保存する。2回目以降のレンダリング(Update)時、Reactは「あ、もう値はあるから、引数の初期値はスルーして今のStateを返そう」と判断する。

問題なのは、「Reactが初期値をスルーするかどうかを決める『前』に、JSのエンジン側で引数の式(関数実行)が評価されてしまっている」という点だ。

だからこそ、「初回だけ実行して、2回目以降の再レンダリング時はその関数自体を呼び出さないでくれよ」とReactに伝える仕組みが必要になる。それが、今回主役の遅延初期化(Lazy Initialization)なんだ。

—

解決策:関数を「渡す」のではなく、関数を「返す(アロー関数等で包む)」

やり方は驚くほどシンプルだ。`useState` の引数に「実行結果」を渡すのではなく、「初期値を計算するための関数そのもの」を渡す。これだけ。

// ⭕️ 遅延初期化の正しい形
const [state, setState] = useState(() => {
return expensiveCalculation();
});

こう書くことで、Reactはこの関数を初回レンダリング時(Mount時)にのみ、こっそりと1回だけ実行してくれる。そして、2回目以降の再レンダリング時には、この関数そのものが完全に無視されるため、無駄な計算コストが一切発生しなくなるというわけだ。

—

【実践】現場でそのまま使える!ローカルストレージ同期コンポーネント

百聞は一見にしかず。実務でよくある、`localStorage` から初期値を安全かつ効率的に読み込むコンポーネントのサンプルコードを見てみよう。

import React, { useState } from ‘react’;

// 初期化コストがやや高い、あるいは副作用のある処理のモック
const getInitialSettings = () => {
console.log(‘【重い処理】localStorageから設定を読み込んでいます…’);
try {
const saved = localStorage.getItem(‘app_settings’);
if (saved) {
return JSON.parse(saved);
}
} catch (error) {
console.error(‘設定の読み込みに失敗しました’, error);
}
// フォールバック用のデフォルト値
return { theme: ‘light’, notifications: true };
};

export const SettingsPanel = () => {
// ★ ここがポイント!
// getInitialSettings() と直書きせず、アロー関数でラップして渡すことで遅延初期化する。
// これにより、このコンポーネントが何回再レンダリングされても、この関数は初回しか走らない。
const [settings, setSettings] = useState(() => getInitialSettings());

const toggleTheme = () => {
setSettings((prev) => {
const nextSettings = {
…prev,
theme: prev.theme === ‘light’ ? ‘dark’ : ‘light’,
};
// ついでにlocalStorageも更新
localStorage.setItem(‘app_settings’, JSON.stringify(nextSettings));
return nextSettings;
});
};

return (

);
};

このコードをブラウザで動かしてみると、ボタンを何度ポチポチと押して再レンダリングを発生させても、コンソールには最初の1回しか「【重い処理】…」のログが出ないことが確認できるはずだ。もしここに遅延初期化を使っていなかったら、ボタンを押すたびに無駄な `localStorage.getItem` と `JSON.parse` が走り続けることになっていた。積もり積もってアプリ全体のモッサリ感に繋がっていく典型的なパターンさ。

—

シニアからの実践的なアドバイス・まとめ

さて、最後に現場で判断に迷いがちなポイントをいくつかシェアしておこう。

1. すべての `useState` に関数を渡す必要はない
`useState(0)` や `useState(‘123’)`、単純なブール値やプリミティブな値を初期値にする場合、計算コストはほぼゼロに等しい。こういうものにまで関数をわざわざ書くとかえってコードが冗長になるので不要だ。
2. 「いつ使うべきか」の判断基準

  • 実行にミリ秒単位以上の時間がかかりそうな重い計算
  • `localStorage` や `sessionStorage` などのストレージからの同期読み出し
  • DOMの計測(`getBoundingClientRect` など)を伴う初期値の取得

これらに該当する場合は、脊髄反射レベルでアロー関数による遅延初期化を思い出せるようになってほしい。

フロントエンドのパフォーマンスチューニングってのは、派手なアーキテクチャの導入よりも、こうした日々の細かい「当たり前」の積み重ねだったりするんだ。ぜひ今日のコードから意識して取り入れてみてくれよな!

コメント

タイトルとURLをコピーしました