• React
    • Performance
    • Tips

    React useState Lazy Initialization Explained

    By Published Updated 5 min read

    Why useState(expensive()) runs on every render and how passing an initializer function makes React compute initial state only once.

    Lazy initialization means passing useState a function, as in useState(() => computeInitialValue()), instead of an already computed value. React calls that initializer function only during the component's first render and skips it on every re-render (React docs: avoiding recreating the initial state). It stops expensive setup work, such as parsing a large JSON value from localStorage, from running again each time the component updates.

    Key takeaways

    • useState(expensive()) calls expensive() on every render, even though React only uses the result from the first one.
    • useState(() => expensive()) or useState(expensive) runs it only when the component first mounts.
    • Use it when the initial value is costly to compute: reading and parsing storage, building large arrays or lookup tables.
    • For simple values such as 0, '' or [], a plain value is clearer; there is nothing to skip.
    • In development Strict Mode, React calls initializer functions twice, so keep them pure.

    The symptom: an input that feels sticky

    Picture a component where typing into a simple input feels sticky, as if the UI lags behind your fingers. It is tempting to reach for useMemo, useCallback or a restructured component tree. But sometimes the re-renders themselves are not the problem. The problem is ordinary JavaScript work that runs during every render.

    Here is the kind of line that causes it:

    const [userSettings, setUserSettings] = useState(getComplexSettings());
    

    See it? It looks innocent, and we write lines like this every day. But it has a cost that many React tutorials gloss over.

    Why does useState(expensive()) run on every render?

    This isn't a React rule; it's a JavaScript rule.

    Function arguments are evaluated before the function is called.

    Look at this plain JavaScript example:

    function cookSteak() {
      console.log("Cooking steak...");
      return "steak";
    }
    
    function eat(food) {
      // Not hungry, so the food is ignored.
      return;
    }
    
    // JavaScript calls cookSteak() FIRST, then passes the result to eat().
    eat(cookSteak()); // Logs "Cooking steak..." anyway
    

    Even though eat does nothing with its argument, cookSteak() has already run. The CPU time is spent.

    Now apply this to React:

    function UserProfile() {
      // React calls this component function on every render.
    
      // 1. heavyCalculation() runs and costs CPU time.
      // 2. After the first render, React already has state for this hook,
      //    so it ignores the new value.
      const [data, setData] = useState(heavyCalculation());
      const [text, setText] = useState("");
    
      return <input value={text} onChange={(e) => setText(e.target.value)} />;
    }
    

    Every character you type calls setText, which re-renders UserProfile. And every re-render calls heavyCalculation() again. And again.

    Suppose that calculation takes 50ms. Typing "Hello World" (11 characters) would spend about 550ms, more than half a second, blocking the main thread to produce values React immediately throws away.

    How does lazy initialization fix it?

    Instead of passing the value, pass a function that returns the value. The React docs call this an initializer function; it is also known as lazy initial state.

    // Eager: heavyCalculation() runs on every render
    const [eagerData, setEagerData] = useState(heavyCalculation());
    
    // Lazy: heavyCalculation() runs only on the first render
    const [lazyData, setLazyData] = useState(() => heavyCalculation());
    
    // Also lazy: pass the function itself, without calling it
    const [alsoLazyData, setAlsoLazyData] = useState(heavyCalculation);
    

    See that little () =>? That changes everything. Now you are handing React a function instead of its result:

    • On the first render: React calls your function and stores the return value as the initial state.
    • On re-renders: React already has state for this hook, so it doesn't call your function at all.

    Passing the function name without parentheses (useState(heavyCalculation)) only works when the function needs no arguments, because React calls it with none.

    See the difference yourself

    Paste this into the React playground, type into the input and watch the console:

    import { useState } from "react";
    
    function buildList(label) {
      console.log(`${label} initializer ran`);
      return Array.from({ length: 10000 }, (_, i) => i);
    }
    
    export default function App() {
      const [eagerList] = useState(buildList("Eager"));
      const [lazyList] = useState(() => buildList("Lazy"));
      const [text, setText] = useState("");
    
      return (
        <div>
          <input
            value={text}
            onChange={(e) => setText(e.target.value)}
            placeholder="Type here"
          />
          <p>Both lists have {eagerList.length} and {lazyList.length} items.</p>
        </div>
      );
    }
    

    "Eager initializer ran" appears on every keystroke, while "Lazy initializer ran" appears only when the component mounts. If the app runs in Strict Mode, you may see duplicate messages, because in development React intentionally calls component functions and initializers twice to surface impure code (React docs: StrictMode).

    A common real-world case: reading localStorage

    You might be thinking, "I don't run heavy math in my components." But do you read from localStorage?

    // Reads and parses storage on every render
    const [config, setConfig] = useState(
      JSON.parse(localStorage.getItem("config") ?? "{}")
    );
    

    localStorage.getItem() returns its value synchronously, so the read happens on the main thread during render. JSON.parse then does work that grows with the size of the stored string. None of that is needed after the first render, and it adds up when several components on a page do the same thing on every keystroke.

    The fix:

    const [config, setConfig] = useState(() => {
      return JSON.parse(localStorage.getItem("config") ?? "{}");
    });
    

    When should you use lazy initialization?

    Don't add arrow functions to every useState call. If your initial state is a simple value, there is no expensive work to skip, and the plain value is easier to read.

    You don't need it for:

    useState(0);
    useState("hello");
    useState(true);
    useState([]);
    

    It is worth using for:

    1. Storage access: Reading and parsing localStorage or sessionStorage.
    2. URL parsing: Building larger state from new URLSearchParams(window.location.search).
    3. Large data structures: Creating big arrays, maps or lookup tables.
    4. Measured slow work: Any calculation that console.time() or the React DevTools Profiler shows is noticeably slow.

    Keep initializer functions pure: they should compute and return a value without side effects such as network requests or subscriptions. Side effects belong in useEffect, which the React hooks guide covers.

    Summary

    Lazy initialization is one of the simplest performance fixes in React. It requires no architectural changes, just a () => in front of the expensive call.

    Next time an input feels sticky, check your useState calls. You might be cooking a steak that nobody eats. And if you need to rerun an initializer on purpose, for example when switching to a different user, see how to reset a component with the key prop.

    Frequently asked questions

    What is lazy initialization in React useState?

    Lazy initialization means passing a function to useState instead of a value. React calls that function only on the component's first render and stores its return value as the initial state. On later renders the function is skipped, so expensive setup work, such as parsing stored data, runs once instead of on every render.

    Does useState(() => value) run on every render?

    No. The arrow function itself is created on every render, which is cheap, but React only calls it during the initial render, so the expensive work inside runs once per mount. The exception is development Strict Mode, where React deliberately calls initializer functions twice to help you find impure code.

    What is the difference between useState(fn) and useState(fn())?

    Passing fn hands React the function itself, which React calls once, on the first render. Writing fn() calls the function immediately during every render and passes only its result, which React ignores after the first render. Pass the function without parentheses only when it needs no arguments, because React calls it with none.

    Should I use lazy initialization for every useState call?

    No. For simple initial values such as numbers, strings, booleans or empty arrays, there is nothing expensive to skip, so a plain value is clearer. Reserve the function form for initial values that require real work, such as reading storage, parsing JSON or building large data structures, and measure when you are unsure.

    How is lazy initialization different from useMemo?

    A lazy initializer computes a value once, when the component mounts, and the result becomes state that you change with the setter. useMemo recalculates a derived value whenever its dependencies change. Use a lazy initializer for starting state, and useMemo for values that must stay in sync with props or other state.

    Try the examples yourself

    Open the React playground to run and edit code from this article in your browser. There is nothing to install and no account to create.