Skip to main content

Command Palette

Search for a command to run...

useState Hook in React [part-2]

Boost Performance with React Lazy Initialization using useState on First Render

Updated
5 min read
useState Hook in React [part-2]

Performance optimization often feels like premature optimization. But when initializing state requires expensive computations, lazy initialization becomes essential. This technique prevents unnecessary work on every render, executing initialization logic exactly once.

The Problem with Direct Initialization

Consider a component that initializes state from localStorage:

function UserPreferences() {
  // This runs on EVERY render
  const savedTheme = JSON.parse(localStorage.getItem('theme') || '{}');
  const [preferences, setPreferences] = useState(savedTheme);

  return <div>Theme: {preferences.mode}</div>;
}

The localStorage read and JSON parsing execute on every render, even though useState only uses the initial value once. For a component that re-renders frequently, this wastes significant processing time.

Lazy Initialization: Function-Based Initial State

useState accepts a function as its initial value. React calls this function only during the initial render, skipping it on subsequent re-renders.

function UserPreferences() {
  // This runs ONLY on the initial render
  const [preferences, setPreferences] = useState(() => {
    const saved = localStorage.getItem('theme');
    return saved ? JSON.parse(saved) : { mode: 'light', fontSize: 16 };
  });

  return <div>Theme: {preferences.mode}</div>;
}

The arrow function wraps the initialization logic. React invokes it once, caches the result, and ignores the function on future renders.

Real-World Use Case: Complex Data Processing

import { useState } from 'react';

function DataAnalysisDashboard({ rawDataset }) {
  const [processedData, setProcessedData] = useState(() => {
    console.log('Processing dataset...');

    // Expensive operations
    const filtered = rawDataset.filter(item => item.isValid);
    const sorted = filtered.sort((a, b) => b.value - a.value);
    const aggregated = sorted.reduce((acc, item) => {
      const key = item.category;
      acc[key] = (acc[key] || 0) + item.value;
      return acc;
    }, {});

    return {
      items: sorted.slice(0, 100),
      summary: aggregated,
      total: filtered.length
    };
  });

  return (
    <div>
      <h2>Analysis Results</h2>
      <p>Total Items: {processedData.total}</p>
      <ul>
        {processedData.items.map(item => (
          <li key={item.id}>{item.name}: {item.value}</li>
        ))}
      </ul>
    </div>
  );
}

Without lazy initialization, this filtering, sorting, and aggregation would execute on every render, degrading performance dramatically as the dataset grows.

Production Pattern: Form with Persisted State

function AdvancedContactForm() {
  const [formData, setFormData] = useState(() => {
    try {
      const saved = sessionStorage.getItem('contactFormDraft');
      if (saved) {
        const parsed = JSON.parse(saved);
        // Validate the structure
        if (parsed.name && parsed.email && parsed.message) {
          return parsed;
        }
      }
    } catch (error) {
      console.error('Failed to load draft:', error);
    }

    // Return default structure
    return {
      name: '',
      email: '',
      message: '',
      subscribe: false,
      priority: 'normal'
    };
  });

  const updateField = (field, value) => {
    const updated = { ...formData, [field]: value };
    setFormData(updated);

    // Persist changes
    sessionStorage.setItem('contactFormDraft', JSON.stringify(updated));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    // Submit logic
    sessionStorage.removeItem('contactFormDraft');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={formData.name}
        onChange={(e) => updateField('name', e.target.value)}
        placeholder="Your name"
      />
      <input
        type="email"
        value={formData.email}
        onChange={(e) => updateField('email', e.target.value)}
        placeholder="Email address"
      />
      <textarea
        value={formData.message}
        onChange={(e) => updateField('message', e.target.value)}
        placeholder="Your message"
      />
      <label>
        <input
          type="checkbox"
          checked={formData.subscribe}
          onChange={(e) => updateField('subscribe', e.target.checked)}
        />
        Subscribe to newsletter
      </label>
      <button type="submit">Send Message</button>
    </form>
  );
}

This form demonstrates multiple lazy initialization benefits: error handling, validation, and default fallback values. Users never lose their draft, even after accidental navigation.


When to Use Lazy Initialization

  • Reading from Browser APIs: localStorage, sessionStorage, IndexedDB access involves I/O operations.

  • Complex Calculations: Mathematical operations, data transformations, or filtering large datasets.

  • Expensive Object Creation: Building complex nested structures or instantiating classes.

  • Reading URL Parameters: Parsing window.location.search or constructing URLSearchParams objects.

When NOT to Use Lazy Initialization

Simple Values: Don't wrap primitives in functions:

// Unnecessary
const [count, setCount] = useState(() => 0);

// Better
const [count, setCount] = useState(0);

Props as Initial State: If the value comes from props and is already computed:

// Unnecessary
const [value, setValue] = useState(() => props.initialValue);

// Better
const [value, setValue] = useState(props.initialValue);

Performance Comparison

// Without lazy initialization
function SlowComponent() {
  const expensiveValue = computeExpensiveValue(); // Runs every render
  const [data, setData] = useState(expensiveValue);

  const [counter, setCounter] = useState(0);

  // Every click triggers expensive computation
  return <button onClick={() => setCounter(counter + 1)}>Clicks: {counter}</button>;
}

// With lazy initialization
function FastComponent() {
  const [data, setData] = useState(() => computeExpensiveValue()); // Runs once
  const [counter, setCounter] = useState(0);

  // Clicks no longer trigger expensive computation
  return <button onClick={() => setCounter(counter + 1)}>Clicks: {counter}</button>;
}

In the slow version, every button click re-renders the component and re-computes the expensive value, even though it's never used again. The fast version (functional way) computes it once and skips it on all future renders.


Common Mistakes

Returning Undefined

// Wrong: function doesn't return anything
const [data, setData] = useState(() => {
  localStorage.getItem('data');
});

// Correct: explicit return
const [data, setData] = useState(() => {
  return localStorage.getItem('data');
});

Using Function Results Instead of Functions

// Wrong: calls the function immediately
const [data, setData] = useState(computeValue());

// Correct: passes the function itself
const [data, setData] = useState(() => computeValue());

The difference is subtle but critical. The first example calls computeValue() on every render. The second passes a function that React calls only once.


Debugging Lazy Initialization

Add logging to verify initialization runs only once:

const [data, setData] = useState(() => {
  console.log('Initializing state - should see this once');
  return expensiveComputation();
});

In React StrictMode (development), you'll see the log twice due to double-rendering, but in production, it executes exactly once.


Integration with TypeScript

interface UserPreferences {
  theme: 'light' | 'dark';
  language: string;
  notifications: boolean;
}

function Settings() {
  const [prefs, setPrefs] = useState<UserPreferences>(() => {
    const saved = localStorage.getItem('preferences');
    if (saved) {
      return JSON.parse(saved) as UserPreferences;
    }
    return {
      theme: 'light',
      language: 'en',
      notifications: true
    };
  });

  return <div>Current theme: {prefs.theme}</div>;
}

TypeScript inference works seamlessly with lazy initialization, providing type safety for your initial state function.


Notes (Key points)

Lazy initialization optimizes performance by deferring expensive computations until absolutely necessary and ensuring they execute only once. Wrap initialization logic in a function when reading from browser APIs, processing data, or performing calculations.

Skip lazy initialization for simple values and computed props. The function wrapper adds unnecessary overhead for trivial initializations.

Master this pattern to build responsive React applications that feel instant, even when working with complex data structures and expensive operations.