Understanding React Lazy Initialization and Its Principles

profile image

Learn how to optimize performance with React useState lazy initialization and understand its working principles through React source code analysis.

This post has been translated by DeepL . Please let us know if there are any mistranslations!

What is Lazy Initialization?

Lazy initialization in React is a useful technique when initialization tasks are costly or time-consuming during state initialization.

Why Lazy Initialization is Necessary

Let's look at the following code example. The issue with the code below is that the function passed as the initial value to useState is called every time the React component re-renders.

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

  // Accessing localStorage, complex calculations, API calls, etc.
  // Time-consuming operations...

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

export const ProblemComponent = () => {
  // Problem: veryHeavyWork is called on every re-render
  const [expensiveState, setExpensiveState] = useState(veryHeavyWork());
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increase (triggers re-render)
      </button>
    </div>
  );
};

In the code above, the veryHeavyWork function is called every time the "Increase" button is clicked. However, since expensiveState is already initialized and memoized by the react-reconciler, the return value of this function is ignored, resulting in performance waste.

console1.webp

Applying Lazy Initialization

In such cases, using lazy initialization can prevent expensive functions from being executed again during re-renders if they've already been set as initial values. The method is simple: instead of passing the result of a function execution, pass a function that returns a function.

typescript
export const OptimizedComponent = () => {
  // Solution: Pass a function that returns a function
  const [expensiveState, setExpensiveState] = useState(() => veryHeavyWork());
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increase (optimized)
      </button>
    </div>
  );
};

console2.webp

In other words, this can be resolved by delegating the execution timing to React rather than determining it directly as a developer. This technique is called "Lazy Initialization" because it delays execution until necessary.

JavaScript Evaluation Timing

Initialization

Before passing arguments to useState, the veryHeavyWork function is executed at the JavaScript code evaluation stage. Since re-rendering means re-executing the component function, this function is called each time. After function execution, the return value is passed to useState, but this value is ignored because the react-reconciler uses the existing value stored in the hook's memoizedState.

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

Re-rendering

However, if written as below, a function that returns a function is passed as a value to useState before executing the veryHeavyWork function. Therefore, it is not executed at the time JavaScript evaluates the code.

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

How React Makes This Decision

Initialization

How does React make this determination? Let's analyze the React code!

When an initial value is provided, mountState is executed. We can see that it returns memoizedState and dispatch as return values. These are the values we use as const [state, setState] = useState().

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

``

Now let's look at the implementation, mountStateImpl. If the initial value comes in as a function, we can see that it stores the result of executing that function in initialState, and then stores that value in memoizedState again.

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

Re-rendering

Now let's look at what code operates during re-rendering. During re-rendering, rerenderStatererenderReducer operate in sequence. As you can see from the code, it simply retrieves and returns the previously stored 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;

  // Executed if dispatch was called in the previous rendering phase
  if (lastRenderPhaseUpdate !== null) {
    // omitted
  }
  return [newState, dispatch];
}

When Should You Use It?

As explained above, it's good to use when generating initial values takes a long time to compute. For example, when accessing localStorage or sessionStorage, or when using array methods.

  1. Accessing localStorage/sessionStorage

    javascript
    const [user, setUser] = useState(() => {
      const savedUser = localStorage.getItem('user');
      return savedUser ? JSON.parse(savedUser) : null;
    });
  2. Generating Complex Initial Data

    javascript
    const [chartData, setChartData] = useState(() => {
      return generateComplexChartData(rawData);
    });
  3. Processing Large Arrays or Objects

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

Difference from Initialization Using useEffect

There are several differences between lazy initialization with useState and initializing state when a component renders using useEffect.

Initialization Timing

  • Lazy Initialization: Sets the value directly at the time of state variable declaration. It works synchronously and the value is set before the component first renders, so the value can be used before the component is mounted and the first render occurs.
  • useEffect: Since it initializes after the component has rendered, you may notice the value changing on the screen.

Use Cases

  • Lazy Initialization: When you need to use a value before the component mounts
  • useEffect: For asynchronous tasks or when side effects occur
❤️ 0
🔥 0
😎 0
⭐️ 0
🆒 0