ReactJS Hooks: useState and useEffect

ReactJS introduced two new features, called hooks, in its newer versions: useState and useEffect. These hooks allow functional components to have their own state and lifecycle methods, which used to be possible only with class components.

Before Hooks: Class Components

In older versions of React, if a component needed to change based on user actions or data updates, we had to use class components. For simple display purposes, we used functional components.

Example: Greeting Component with a Class

Imagine a UI that asks for a username and displays a greeting:

class Greeting extends Component {
  constructor(props) {
    super(props);
    this.state = {
      name: '' // Initial state for name
    };
  }

  handleChange = (event) => {
    this.setState({ name: event.target.value });
  }

  render() {
    return (
      <div>
        <input
          type="text"
          value={this.state.name}
          onChange={this.handleChange}
          placeholder="Enter your name"
        />
        <p>Hello, {this.state.name || "stranger"}!</p>
      </div>
    );
  }
}

In this example:

  • The state (name) is defined in the constructor.

  • handleChange updates the state when the input changes.

  • render method re-renders the UI whenever the state changes.

With Hooks: Functional Components

With hooks, we can achieve the same functionality in a simpler way using functional components.

Example: Greeting Component with Hooks

const Greeting = () => {
  const [username, setUsername] = useState('');

  const handleChange = (e) => {
    setUsername(e.target.value);
  };

  return (
    <div>
      <input
        type="text"
        placeholder="Enter your name"
        value={username}
        onChange={handleChange}
      />
      {username && <h2>Hello, {username}!</h2>}
    </div>
  );
};

In this example:

  • useState hook creates a state variable username and a function setUsername to update it.

  • The handleChange function updates the state.

  • The component re-renders whenever the state changes.

How Hooks Work

You might wonder how the state is retained in functional components since they are just functions that get called repeatedly. The answer lies in the virtual DOM, a concept in React that helps efficiently update the user interface.

When useState is called, React stores the state in a special place called a Fiber node within the virtual DOM. This allows React to remember the state across re-renders and component navigations.

React creates a virtual DOM tree as it parses JSX, starting from the root element, and adds a node called a Fiber whenever it encounters a JSX element tag. The Fiber node contains details such as the type of element, parent, sibling, children, and more.

Each component has a virtual DOM beneath it, consisting of all the components generated by its JSX. Relevant details stored in a Fiber node include:

  • type: The component type (function, class, etc.).

  • key: A unique key for identifying nodes.

  • stateNode: The local state of the component.

The stateNode is present for both class-based and function-based components.

For class-based components, the stateNode is a JavaScript object that is updated whenever the setState method of the component is called. For function components, the stateNode is a list of tuples. Each tuple contains a variable and its setter method.

Class Components

For a class component, whenever it is to be rendered, React invokes the render method, creates a new virtual DOM, compares it with the earlier DOM, and replaces it where required. It also makes a copy of the state of the component and stores it in stateNode.

Functional Components

For functional components, the flow is slightly different. React executes the function every time a re-render is required. When the function is called, the lines with useState calls are executed.

useState refers to a global variable to get the current Fiber node. It checks if stateNode is empty. If it is empty, it initializes an index counter (e.g., variableIndex) to 0 and creates a tuple of [variable, setterFunction] and appends it to stateNode. If stateNode is not empty, it picks the tuple at the current index (0) and returns it, then increments variableIndex.

Next time useState is called, it will now pick up the variable at index 1, and so on. This is why it is mandatory to put all the useState statements at the top of the function and not in any conditional statements; this ensures the correct ordering since it is all referred to by position.

In summary, the state is stored in the Fiber node.

How useState Works

  1. Initialization:

    • When a component first renders, useState initializes state and stores it in a special structure within a Fiber node in the virtual DOM.

    • For example, const [count, setCount] = useState(0); will store count and setCount as a tuple in the Fiber node.

  2. Re-rendering:

    • When the component re-renders (due to state or props changes), React needs to retrieve the state values in the same order they were declared.

    • React keeps an internal counter (let's call it stateIndex) that tracks the current position of the state in the list of tuples stored in the Fiber node.

  3. Order Matters:

    • The order of useState calls must remain consistent between renders.

    • During each render, React will retrieve state based on the stateIndex.

    • On the first useState call, stateIndex is 0, so it picks the first state tuple. For the second useState call, stateIndex is 1, so it picks the second state tuple, and so on.

Why Order Matters

If you place useState calls inside conditional statements, the order of useState calls could change between renders. This would break the consistency required by React to correctly retrieve state values.

Example

Consider this component:

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

  if (someCondition) {
    const [extra, setExtra] = useState(0);
  }

  return (
    <div>
      <p>Count: {count}</p>
      {/* other component code */}
    </div>
  );
}
  • Initial Render (someCondition is true):

    1. Initialization:

      • const [count, setCount] = useState(0); is called. React assigns this to stateIndex 0.

      • stateIndex is incremented to 1.

      • if (someCondition) { const [extra, setExtra] = useState(0); } is true, so extra is initialized at stateIndex 1.

      • stateIndex is incremented to 2.

    2. State Tracking:

      • The Fiber node now has two state entries: stateIndex 0 for count and stateIndex 1 for extra.

Second Render (someCondition becomes false):

  1. Initialization:

    • const [count, setCount] = useState(0); is called again. React assigns this to stateIndex 0 as expected.

    • stateIndex is incremented to 1.

    • if (someCondition) { const [extra, setExtra] = useState(0); } is now false, so extra is not initialized.

  2. State Tracking:

    • React expects a state entry at stateIndex 1, but because the useState call for extra is skipped, there is a mismatch.

Consequences:

  • State Desynchronization:

    • The stateIndex for subsequent useState calls will be off by one.

    • React might return incorrect state values for any further hooks, leading to unexpected behavior or bugs.

Correct Usage

To ensure the correct order, always place useState (and other hooks) at the top level of your component function:

function Counter() {
  const [count, setCount] = useState(0);
  const [extra, setExtra] = useState(0);

  if (someCondition) {
    // Use the extra state only if needed
  }

  return (
    <div>
      <p>Count: {count}</p>
      {/* other component code */}
    </div>
  );
}

In this corrected version, useState calls are at the top level, maintaining their order between renders regardless of conditional logic inside the component. This ensures React can reliably track and update state.

Comparing Class Components and Functional Components

React strongly recommends the use of hooks and says that new APIs will be designed with hooks in mind. However, there are trade-offs between class-based and function-based components.

Class Components

  • Easier Visualization: For people used to the object-oriented paradigm, classes are easier to visualize. Traditionally, functions are verbs, and classes are nouns. Classes were called things like Counter or Employee, and their methods were called increment, initiateExit, etc.

  • System Visualization: It was easier to visualize the system in terms of class and sequence diagrams, with a set of classes sending messages to each other in response to user actions.

  • Simpler Understanding: With classes, it is easier to understand what is happening without ever knowing about the virtual DOM tree or the Fiber architecture.

  • Explicit Contracts: Every class interface is a contract between a client and a service. This makes it clear what functionality belongs in a class and simplifies the signature of each function.

  • Testability: Since all the state is inside the class, it is easier to test without the React framework.

Functional Components

  • State Reuse: The functional approach with hooks allows for the reuse of stateful logic.

  • React's Future: React's future API development is focused on hooks, making them a more forward-looking choice.

  • Understanding Virtual DOM: An important part of the functionality of functional components rests within the virtual DOM, requiring an understanding of the virtual DOM architecture to code properly.

  • Side Effects: Pure functions take some input, perform calculations, and return a value without side effects. In classes, methods transform the state internally. In functional components, an invocation updates the entire state tree, making side effects seem global.

Conclusion

Hooks like useState and useEffect make it possible to use state and lifecycle methods in functional components, simplifying the code while maintaining the same functionality as class components. Although it requires some understanding of React's internal workings, the shift to hooks provides a more modern and streamlined way to build React applications.