Reactの遅延初期化(lazy initialization)とその原理を理解する

profile image

React useStateの遅延初期化(lazy initialization)でパフォーマンスを最適化する方法を学び、Reactのソースコード分析を通じてその動作原理を理解しましょう。

この記事は DeepL によって翻訳されました。誤訳があれば教えてください!

遅延初期化(lazy initialization)とは?

Reactにおける遅延初期化は、状態(state)の初期化時点で初期化作業のコストが高かったり、時間がかかったりする場合に便利に使用できるテクニックです。

遅延初期化が必要な理由

例えば、次のようなコードを見てみましょう。以下のコードでは、Reactコンポーネントが再レンダリングされるたびに、useStateの初期値として渡された関数が毎回呼び出されるという問題があります。

typescript
const veryHeavyWork = () => {
  console.log('very heavy work called');

  // localStorageへのアクセス、複雑な計算、API呼び出しなど
  // 時間のかかる作業...

  return { data: 'expensive data' };
};

export const ProblemComponent = () => {
  // 問題:再レンダリングのたびにveryHeavyWorkが呼び出される
  const [expensiveState, setExpensiveState] = useState(veryHeavyWork());
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        増加再レンダリングが発生
      </button>
    </div>
  );
};

上記のコードでは、「増加」ボタンをクリックするたびにveryHeavyWork関数が呼び出されます。しかし、expensiveStateはすでに初期化されてreact-reconcilerによってメモ化されているため、この関数の戻り値は無視され、パフォーマンスの無駄が発生します。

console1.webp

遅延初期化を適用する

このような場合、遅延初期化を使用すると、再レンダリングが行われても初期値として設定されていれば、コストの高い関数を再度実行しないようにできます。方法は簡単です。関数の実行結果を渡すのではなく、関数を返す関数を渡します。

typescript
export const OptimizedComponent = () => {
  // 解決策:関数を返す関数を渡す
  const [expensiveState, setExpensiveState] = useState(() => veryHeavyWork());
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        増加最適化済み
      </button>
    </div>
  );
};

console2.webp

つまり、関数の実行タイミングを開発者が直接決めるのではなく、Reactに委任することで解決できます。このように必要になるまで実行を遅らせる技法から「遅延初期化(Lazy Initialization)」という名前が付けられました。

JavaScriptの評価タイミング

初期化

useStateに引数を渡す前に、JavaScriptコードの評価時点でveryHeavyWork関数がまず実行されます。再レンダリングするということはコンポーネント関数を再度実行することなので、この関数は毎回呼び出されます。関数実行後の戻り値がuseStateに渡されますが、react-reconcilerがフックのmemoizedStateに保存されている既存の値を使用するため、この値は無視されます。

typescript
const [expensiveState, setexpensiveState] = useState(veryHeavyWork());

再レンダリング

しかし、以下のように記述すると、veryHeavyWork関数を実行する前に、関数を返す関数が値としてuseStateに渡されます。したがって、JavaScriptがコードを評価する時点では実行されません。

typescript
const [expensiveState, setexpensiveState] = useState(() => veryHeavyWork());

Reactでの判断プロセス

初期化

ではReactではこのような判断をどのように行っているのでしょうか?Reactのコードを分析してみましょう!

初期値が入ってくるとmountStateが実行されます。戻り値としてmemoizedStateとdispatchを返しているのが分かります。これらの値が私たちが使用するconst [state, setState] = useState()です。

typescript
function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  // 省略...
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

``

次に実装であるmountStateImplを見てみましょう。初期値が関数として入ってきた場合、その関数を実行した結果をinitialStateに格納し、その値を再びmemoizedStateに格納しているのが分かります。

typescript
function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    const initialStateInitializer = initialState;
    initialState = initialStateInitializer();
    // 省略...
  }
  hook.memoizedState = hook.baseState = initialState;
  // 省略..
  return hook;
}

再レンダリング

次に再レンダリング時にどのようなコードが動作するかを見てみましょう。再レンダリング時にはrerenderStatererenderReducerの順に動作します。コードを見れば分かるように、既存に保存されたmemoizedStateを取得して返すだけです。

typescript
function rerenderReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook();
  let newState = hook.memoizedState;

  // 前のレンダリングフェーズでdispatchが呼び出された場合に実行
  if (lastRenderPhaseUpdate !== null) {
    // 省略
  }
  return [newState, dispatch];
}

いつ使うと良いか?

上で説明したように、初期値の生成に計算時間がかかる場合に使うと良いです。例えば、localStoragesessionStorageにアクセスする場合、配列に関するメソッドを使用する場合などです。

  1. localStorage/sessionStorageへのアクセス

    javascript
    const [user, setUser] = useState(() => {
      const savedUser = localStorage.getItem('user');
      return savedUser ? JSON.parse(savedUser) : null;
    });
  2. 複雑な初期データの生成

    javascript
    const [chartData, setChartData] = useState(() => {
      return generateComplexChartData(rawData);
    });
  3. 大きな配列やオブジェクトの処理

    javascript
    const [processedItems, setProcessedItems] = useState(() => {
      return largeDataSet.map(item => ({
        ...item,
        processed: true,
        timestamp: Date.now()
      }));
    });

useEffectを使用した初期化との違い

useStateの遅延初期化とuseEffectを使ってコンポーネントがレンダリングされるときに状態を初期化することの間にはいくつかの違いがあります。

初期化タイミング

  • 遅延初期化: 状態変数宣言時に直接値を設定します。同期的に動作し、コンポーネントが最初にレンダリングされる前に値が設定されるため、コンポーネントがマウントされて最初のレンダリングが行われる前に値を使用できます。
  • useEffect: コンポーネントがレンダリングされた後に初期化されるため、画面上で値が変化するのを感じることができます。

用途

  • 遅延初期化: コンポーネントがマウントされる前に値を使用する必要がある場合
  • useEffect:非同期作業やサイドエフェクトが発生する場合
❤️ 0
🔥 0
😎 0
⭐️ 0
🆒 0