Skip to content

Level Up React: Deep dive into state and useState

Published: at 05:00 PM

About Level Up React Series

Level Up React is a series of in-depth articles designed to help React developers enhance their skills. We explore React’s internal mechanisms, best practices, design patterns, and advanced concepts. These articles are written for React developers who want to go beyond the basics and truly understand how React works under the hood.

Previous Articles in the Series

  1. React: Declarative vs Imperative Programming
  2. React: Deep Dive into React Elements
  3. React: React and React DOM architecture
  4. Level Up React: Functional programming in React

Introduction to state

State is one of the core concepts of React. It represents data that can change over time in a component. Unlike props which are passed by the parent component and are immutable from the child component’s perspective, state is internal to the component and can be modified.

The useState hook is the main solution for managing local state in modern React functional components, forming the basis of interactivity in React applications.

useState hook basics

The useState hook is a function that lets you add local state to a functional component. Let’s see how it works with a simple example:

import React, { useState } from "react";

function Counter() {
  // Declares a state variable "count" initialized to 0
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click</button>
    </div>
  );
}

In this example:

Each time setCount is called, React re-renders the component with the new value of count.

The asynchronous nature of useState

A crucial and often misunderstood aspect of useState is its asynchronous behavior. When you call the update function, React doesn’t immediately change the state value. Instead, it schedules this update, which can cause unexpected bugs.

function AsynchronousExample() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // This line doesn't immediately modify count
    setCount(count + 1);

    // Here, count is still its initial value
    console.log(count); // Shows the previous value of count, not the new one
  };

  return <button onClick={handleClick}>Increment ({count})</button>;
}

To solve this issue, useState offers an alternative form that accepts a function:

function FunctionalUpdateExample() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // Using a function that receives the previous state
    setCount(prevCount => prevCount + 1);

    // If we need to perform multiple updates
    setCount(prevCount => prevCount + 1);
    setCount(prevCount => prevCount + 1);
    // This code increments count by 3
  };

  return <button onClick={handleClick}>Multiple increment ({count})</button>;
}

This functional form ensures that you always work with the most recent value of the state, even if React hasn’t re-rendered the component yet.

Impact on component rendering

Each state update triggers a new rendering of the component. This mechanism allows React to keep the interface up-to-date with the data.

function RenderExample() {
  const [count, setCount] = useState(0);

  console.log("Component rendered with count =", count);

  return (
    <button onClick={() => setCount(count + 1)}>Increment ({count})</button>
  );
}

Each time you click the button, the console message appears, indicating that React has re-rendered the component with the new value of count.

However, these frequent renders can cause performance issues if your component is complex or if multiple states change at the same time.

Lazy initialization

When you use useState, it’s important to understand how React handles the initial value passed as a parameter. There is a crucial difference between directly passing a value and passing an initialization function.

Problem: Recalculation on each render

When you directly pass a value or an expression to useState, this expression is evaluated on each component render:

function ExpensiveInitExample() {
  // ❌ Problematic: complexCalculation() is executed on EVERY render
  const [value, setValue] = useState(complexCalculation());

  console.log("Component rendered");

  return (
    <div>
      <p>Value: {value}</p>
      <button onClick={() => setValue(value + 1)}>Increment</button>
    </div>
  );
}

In this example, even though we only care about the initial value, complexCalculation() will be called on every component render, not just during initialization. This can significantly impact performance if this function is resource-intensive.

Solution: Initialization function

To solve this problem, React allows passing an initialization function to useState. This function will only be called once, during the first render:

function LazyInitExample() {
  // ✅ Correct: the function is called only once, during the first render
  const [value, setValue] = useState(() => {
    console.log("Expensive calculation in progress...");
    return complexCalculation();
  });

  console.log("Component rendered");

  return (
    <div>
      <p>Value: {value}</p>
      <button onClick={() => setValue(value + 1)}>Increment</button>
    </div>
  );
}

function complexCalculation() {
  // Simulating an expensive calculation
  for (let i = 0; i < 1000000; i++) {
    // Intensive calculation
  }
  return 42;
}

In this version, complexCalculation will only be executed once, during the initial mounting of the component, and not on each subsequent render. React simply uses the value returned by the function for initialization, then ignores this function on later renders.

When to use lazy initialization?

Lazy initialization is particularly useful in these situations:

Managing complex objects

When your state is an object or an array, you need to be careful to respect the principle of immutability:

function ObjectStateExample() {
  const [user, setUser] = useState({
    name: "Alice",
    age: 25,
    preferences: {
      theme: "dark",
      notifications: true,
    },
  });

  const updateTheme = newTheme => {
    // ❌ Incorrect - Direct modification of state
    // user.preferences.theme = newTheme;
    // setUser(user); // Won't cause a re-render because the object reference is the same

    // ✅ Correct - Creating a new object
    setUser({
      ...user,
      preferences: {
        ...user.preferences,
        theme: newTheme,
      },
    });
  };

  return (
    <div>
      <p>Name: {user.name}</p>
      <p>Age: {user.age}</p>
      <p>Theme: {user.preferences.theme}</p>
      <button onClick={() => updateTheme("light")}>Change theme</button>
    </div>
  );
}

React compares object references to determine if the state has changed. If you directly modify a state object, React won’t detect the change and won’t re-render the component.

Common pitfalls with useState

1. Closures and stale values

A frequent pitfall involves JavaScript “closures,” where a function captures a value at the time it’s defined:

function ClosureTrapExample() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setTimeout(() => {
      // This function captures the value of count when it's defined
      console.log("Count value:", count);
      setCount(count + 1); // Increments with a potentially stale value
    }, 2000);

    return () => clearTimeout(timer);
  }, []); // Empty dependency array means useEffect runs only once

  return <p>Count: {count}</p>;
}

In this example, the function in setTimeout captures the initial value of count (which is 0). Even if count changes in the meantime, the function will always use this initial value.

The solution is either to add count to the dependency array or to use the functional form:

// Solution with functional form
useEffect(() => {
  const timer = setTimeout(() => {
    setCount(prevCount => prevCount + 1); // Always up to date
  }, 2000);

  return () => clearTimeout(timer);
}, []);

2. Multiple updates

If you need to make multiple state updates based on the same value, remember React’s batching behavior:

function MultipleBatchingExample() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // These three calls are batched and will only increment count by 1
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);

    // To increment by 3, use the functional form:
    // setCount(prev => prev + 1);
    // setCount(prev => prev + 1);
    // setCount(prev => prev + 1);
  };

  return <button onClick={handleClick}>Increment ({count})</button>;
}

Best practices for using useState

Separate concerns

Rather than having a large state object, prefer breaking down your state into independent logically related variables:

// ❌ Less ideal
const [state, setState] = useState({
  name: "",
  email: "",
  isSubmitting: false,
  errors: {},
});

// ✅ Better
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState({});

This approach allows for more targeted updates and improves code readability and maintenance. It’s important to note that this separation doesn’t prevent re-renders by itself - React will still re-render the component when any state changes, whether you use a single object or several separate state variables.

Naming convention

Adopt a consistent naming convention for your state variables and their update functions:

const [count, setCount] = useState(0);
const [isVisible, setIsVisible] = useState(true);
const [user, setUser] = useState(null);

The “set” prefix followed by the state variable name is the standard convention in React.

Extract complex logic

If your state update logic becomes complex, consider extracting it into separate functions:

function UserProfileForm() {
  const [formData, setFormData] = useState({
    username: "",
    email: "",
    bio: "",
  });

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

  const resetForm = () => {
    setFormData({
      username: "",
      email: "",
      bio: "",
    });
  };

  // ... rest of the component
}

This approach makes your code more readable and maintainable.

useState vs useReducer

For complex state logic, consider using useReducer instead of useState:

// With useState (becomes complex with multiple fields)
function FormWithUseState() {
  const [username, setUsername] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  // Many handler functions for each field...
}

// With useReducer (more organized)
function FormWithUseReducer() {
  const initialState = {
    username: "",
    email: "",
    password: "",
    errors: {},
    isSubmitting: false,
  };

  const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case "FIELD_CHANGE":
        return {
          ...state,
          [action.field]: action.value,
        };
      case "SUBMIT_START":
        return {
          ...state,
          isSubmitting: true,
        };
      case "SUBMIT_SUCCESS":
        return {
          ...initialState,
        };
      case "SUBMIT_ERROR":
        return {
          ...state,
          errors: action.errors,
          isSubmitting: false,
        };
      default:
        return state;
    }
  }, initialState);

  // Centralized state management...
}

useReducer is generally preferable when:

Performance optimization

State updates trigger component re-renders, which can impact performance. React offers several optimization techniques that you can use in a targeted way.

React.memo

React.memo is a HOC (Higher-Order Component) that allows memorizing a component and preventing it from re-rendering if its props haven’t changed.

const MemoizedComponent = React.memo(function MyComponent({ name, value }) {
  console.log("Component render");
  return (
    <div>
      {name}: {value}
    </div>
  );
});

This technique is useful for child components that receive the same props across multiple consecutive renders and whose rendering is expensive.

useCallback

The useCallback hook allows memorizing a function between renders. This is particularly useful for functions passed as props to memoized components.

function Parent() {
  // Without useCallback, this function would be recreated on each render
  const handleClick = useCallback(() => {
    console.log("Button clicked");
  }, []); // Empty dependency array = stable function

  return <MemoizedButton onClick={handleClick} />;
}

Without useCallback, each parent component render would create a new function with a different reference, which would cause a re-render of the memoized child component.

useMemo

useMemo memorizes the result of a calculation between renders. It’s useful to avoid recalculating expensive values on each render.

function ExpensiveComponent({ data }) {
  // The expensive processing is only performed if data changes
  const processedData = useMemo(() => {
    return data.map(item => /* complex processing */);
  }, [data]);

  return <div>{processedData.length} processed items</div>;
}

This optimization is particularly relevant for calculations, data transformations, or creating complex objects.

Note on React 19 and the React compiler

With the introduction of the React compiler in React 19, some of these manual optimizations are less necessary than before. The compiler can automatically detect and optimize many cases where React.memo, useMemo, and useCallback would have been necessary in previous versions.

However, these APIs remain useful in complex cases where the compiler cannot automatically optimize, especially:

As a general rule, start without these optimizations and add them only when you identify a specific performance issue.

Conclusion

The useState hook is one of the most fundamental tools in React, allowing functional components to maintain and manage their own state. Its apparent simplicity hides important subtleties, particularly its asynchronous behavior and its impact on the rendering cycle of components.

To master useState, you need to understand:

A good understanding of useState provides a solid foundation for moving on to more advanced hooks like useReducer, useContext, or creating your own custom hooks.