useState Lazy Initialization: Optimize Initial State
When initializing React state with expensive calculations (like reading localStorage or parsing large data), passing a function to useState ensures that computation runs only once during mount—not on every render. This pattern, called lazy initialization, is a critical performance optimization for production React applications.
The Problem: Wasteful Re-computation
The useState hook accepts an initial value. React uses that value only on the very first render; on subsequent re-renders, useState ignores the initializer and returns the current state value. However, if you pass the result of an expensive function, that function still executes on every render—wasting computation.
function createInitialTodos() {
// This computation runs EVERY RENDER even though React only uses it once
console.log('Calculating initial todos...');
const todos = [];
for (let i = 0; i < 10000; i++) {
todos.push({ id: i, text: `Todo ${i}` });
}
return todos;
}
function TodoList() {
// BUG: createInitialTodos() is called on every render!
const [todos, setTodos] = useState(createInitialTodos());
// ...
}
Every time the component re-renders (because a user types, clicks a button, or the parent re-renders), the component function re-runs, and createInitialTodos() executes again. If this function reads from localStorage, makes a synchronous computation, or parses large JSON, the UI becomes sluggish. The cost multiplies quickly in real applications where components render dozens of times during normal user interaction.
The Solution: Pass a Function, Not Its Result
useState has a special feature: if you pass a function as the initial state, React executes that function only once during the initial render and ignores it thereafter. This is lazy initialization.
The syntax difference is critical:
| Pattern | What Happens |
|---|---|
useState(expensiveFunc()) | Function runs immediately every render. Expensive! |
useState(expensiveFunc) | Function runs once on mount only. Optimized! |
Pass the function reference (no parentheses), not the function call (with parentheses).
Here's the corrected TodoList:
import React, { useState } from 'react';
function createInitialTodos() {
// This function now runs ONLY ONCE during component mount
console.log('Calculating initial todos...');
const initialTodos = [];
for (let i = 0; i < 50; i++) {
initialTodos.push({ id: i, text: 'Item ' + (i + 1) });
}
return initialTodos;
}
export default function TodoList() {
// Pass the function itself, not the result of calling it
const [todos, setTodos] = useState(createInitialTodos);
const [text, setText] = useState('');
return (
<>
<input
value={text}
onChange={e => setText(e.target.value)}
/>
<button onClick={() => {
setText('');
setTodos([{ id: todos.length, text: text }, ...todos]);
}}>Add</button>
<ul>
{todos.map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
</>
);
}
Now, open your browser's developer console. You'll see "Calculating initial todos..." logged exactly once when the component first mounts. As you type in the input field, the component re-renders (triggering console.log calls inside the render function), but createInitialTodos() is never called again. The expensive initialization runs only when needed.
Real-World Use Cases for Lazy Initialization
Use lazy initialization when the initial state requires a computation that takes noticeable time to complete. Here are the most common scenarios:
Reading from localStorage:
function MyComponent() {
// Without lazy init: localStorage read on every render
// With lazy init: localStorage read only on mount
const [theme, setTheme] = useState(() => {
const saved = localStorage.getItem('theme');
return saved ? JSON.parse(saved) : 'light';
});
// ...
}
Parsing large JSON data:
const [config, setConfig] = useState(() => {
return JSON.parse(largeJsonString);
});
Complex synchronous calculations:
const [derivedData, setDerivedData] = useState(() => {
return expensiveAlgorithm(rawData);
});
For simple initial values—primitives like 0, '', true, or small hardcoded arrays—lazy initialization offers no performance benefit. Reserve this pattern for genuinely expensive operations.
When NOT to Use Lazy Initialization
Lazy initialization is overkill and adds unnecessary complexity for:
- Simple primitives:
useState(0),useState(''),useState(false) - Small, predefined objects:
useState({ name: 'John', age: 30 }) - Props that rarely change:
useState(props.defaultValue)
Use it only when you can measure a real performance improvement or when the initialization cost is obvious (e.g., reading from localStorage, parsing a multi-MB JSON file, running a heavy calculation).
Key Takeaways
- Functions Run Once: Pass a function to
useStateto defer execution until the initial render only. - Syntax Matters:
useState(fn)is lazy;useState(fn())is eager and wasteful. - Use for Expensive Operations: Lazy initialization is for localStorage reads, large JSON parsing, and complex calculations.
- Simple Values Don't Need It: Primitives and small objects initialize instantly; don't over-complicate them.
- Readable Code: Explicit initializer functions (as opposed to inline arrow functions) make intent clear:
useState(getInitialTheme)signals that theme initialization is non-trivial.
Frequently Asked Questions
What if I need parameters inside the initializer function?
Lazy initializers cannot accept parameters. If you need to initialize state based on props, use useEffect:
const [state, setState] = useState(null);
useEffect(() => {
setState(computeInitialState(props.seed));
}, []);
Can I use async/await in a lazy initializer?
No. Lazy initializers must be synchronous functions. For asynchronous initialization (like fetching data), use useEffect with async/await inside it.
Does lazy initialization work with useReducer?
Yes. useReducer also accepts a lazy initializer as its third argument:
const [state, dispatch] = useReducer(reducer, initialArg, init);
The init function is called with initialArg and should return the initial state.
Is there a performance difference if the initializer is really simple?
No measurable difference if the function is simple (e.g., returns a hardcoded value). The optimization is micro-premature in those cases. Use lazy initialization for operations that take visible time.
Can I use an arrow function as a lazy initializer?
Yes, but prefer named functions for readability:
// Works but less clear
const [todos, setTodos] = useState(() => {
return JSON.parse(localStorage.getItem('todos')) || [];
});
// Better: explicit intent
const getTodos = () => JSON.parse(localStorage.getItem('todos')) || [];
const [todos, setTodos] = useState(getTodos);