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.
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.
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.
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>
);
};
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.
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.
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()
.
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.
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, rerenderState
→ rerenderReducer
operate in sequence. As you can see from the code, it simply retrieves and returns the previously stored memoizedState
.
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.
-
Accessing localStorage/sessionStorage
javascriptconst [user, setUser] = useState(() => { const savedUser = localStorage.getItem('user'); return savedUser ? JSON.parse(savedUser) : null; });
-
Generating Complex Initial Data
javascriptconst [chartData, setChartData] = useState(() => { return generateComplexChartData(rawData); });
-
Processing Large Arrays or Objects
javascriptconst [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