The importance of lazy initialization with useState

5 min read
react hooks optimization

As a React developer, I use useState constantly to manage state in my functional components. However, there’s a powerful but lesser-known feature of this hook: lazy initialization. In this article, I’ll explore what it is, why it’s important, and how to implement it correctly.


What is lazy initialization in useState?

Lazy initialization is a technique that allows me to define the initial state of a component using a function instead of a direct value. React will execute this function only during the initial mount of the component, ignoring it in subsequent renders.

The syntax is straightforward:

// Standard initialization
const [state, setState] = useState(initialValue);

// Lazy initialization
const [state, setState] = useState(() => computeInitialValue());

Why is it important?

I’ve discovered that lazy initialization is especially useful when:

  1. The initial value requires expensive calculations
  2. I need to access localStorage or sessionStorage
  3. I’m performing an operation that shouldn’t be repeated on every render

Without lazy initialization, any operation in the useState argument would run on every render, even though React only uses that value the first time.


Step-by-step example

Let’s look at a practical case where I’ve found that lazy initialization makes a difference:

Problem: Inefficient loading from localStorage

Imagine a component that needs to load its initial state from localStorage:

import React, { useState } from "react";

function UserProfile() {
    // ⚠️ This code is inefficient
    const [user, setUser] = useState(
        JSON.parse(localStorage.getItem("user")) || { name: "", email: "" },
    );

    // Rest of the component...

    return (
        <div>
            <h2>User Profile</h2>
            <p>Name: {user.name}</p>
            <p>Email: {user.email}</p>
            {/* Edit form... */}
        </div>
    );
}

The problem I see here is that JSON.parse(localStorage.getItem('user')) will run on every render, even if React only uses this value during the initial mount.

Solution: Implementation with lazy initialization

import React, { useState } from "react";

function UserProfile() {
    // ✅ Correct lazy initialization
    const [user, setUser] = useState(() => {
        // This function only runs during the initial mount
        const savedUser = localStorage.getItem("user");
        if (savedUser) {
            return JSON.parse(savedUser);
        }
        return { name: "", email: "" };
    });

    const handleChange = (e) => {
        const { name, value } = e.target;
        setUser((prevUser) => ({
            ...prevUser,
            [name]: value,
        }));
    };

    const handleSave = () => {
        localStorage.setItem("user", JSON.stringify(user));
        alert("User saved successfully");
    };

    return (
        <div>
            <h2>User Profile</h2>

            <div>
                <label htmlFor="name">Name:</label>
                <input
                    type="text"
                    id="name"
                    name="name"
                    value={user.name}
                    onChange={handleChange}
                />
            </div>

            <div>
                <label htmlFor="email">Email:</label>
                <input
                    type="email"
                    id="email"
                    name="email"
                    value={user.email}
                    onChange={handleChange}
                />
            </div>

            <button onClick={handleSave}>Save changes</button>
        </div>
    );
}

Measuring the impact with an exaggerated example

To clearly demonstrate the impact, I’ve created an exaggerated example where I calculate something expensive:

import React, { useState } from "react";

// Simulated expensive function
const calculateExpensiveInitialState = () => {
    console.log("⚙️ Calculating initial state (expensive operation)");

    // Simulate a heavy calculation
    let result = 0;
    for (let i = 0; i < 1000000; i++) {
        result += Math.random();
    }

    return result;
};

// Component without lazy initialization
function WithoutLazy() {
    // ⚠️ The expensive function runs on every render
    const [value, setValue] = useState(calculateExpensiveInitialState());

    return (
        <div>
            <p>Value: {value}</p>
            <button onClick={() => setValue(Math.random())}>Update value</button>
        </div>
    );
}

// Component with lazy initialization
function WithLazy() {
    // ✅ The expensive function only runs once during the mount
    const [value, setValue] = useState(() => calculateExpensiveInitialState());

    return (
        <div>
            <p>Value: {value}</p>
            <button onClick={() => setValue(Math.random())}>Update value</button>
        </div>
    );
}

In my tests, if I run these components and check the console:


When do I use lazy initialization?

In my experience, I use lazy initialization when:

  1. The initial value requires expensive calculations
  2. I need to read from localStorage/sessionStorage
  3. I’m performing complex data transformations
  4. I need to generate random values that should remain consistent

I don’t usually use lazy initialization when:

  1. The initial value is simple (number, string, boolean)
  2. The initial value is a constant reference (an empty object or array like [] or {})

Conclusion

Lazy initialization is a simple but powerful technique that I’ve incorporated to optimize the performance of my React components. I always use it when:

It’s a simple pattern that has made a big difference in the performance of my applications, especially in components that handle complex data or interact with browser storage.