anila.

The Gist of using useState to manage states in RFC

author avatar of anila website

The advent of React Hooks has revolutionized the way we write state logic in function components. Among them, the useState Hook is one of the most fundamental and commonly used, allowing us to easily add and manage local state within function components. This article will delve into the usage of useState, from basic concepts to best practices usage, helping you to more effectively understand state management in React Function Components.

What is useState?

useState is a React Hook that allows you to "hook into" React's state features within function components. Simply put, state is data that a component can store and change internally. When the state changes, React automatically re-renders the component to reflect these changes.

With useState, you can:

  1. Declare a state variable: Used to store your data.
  2. Get an updater function: Used to modify the value of the state variable.
  3. Set an initial state: Provide an initial value for the state variable.

Basic Usage of useState

The syntax for useState is very concise:

jsx

import { useState } from 'react';

function MyComponent() {
  const [stateVariable, setStateVariable] = useState(initialValue);

  // ... component logic ...
  return (
    // ... JSX ...
  );
}
  • import { useState } from 'react': First, you need to import useState from React.
  • useState(initialValue): Calls useState and passes in the initial value of the state. This initial value is only used when the component first renders.
  • stateVariable: This is the state variable you declare, which will hold the current state value.
  • setStateVariable: This is the function used to update the value of stateVariable. When you call this function and pass in a new value, React will schedule a re-render of the component.

Example: A Simple Counter

jsx

import React, { useState } from 'react';

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

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

export default Counter;

In this counter example, count is the state variable, and setCount is the function to update it. Each time a button is clicked, setCount is called, the value of count changes, and the component re-renders to display the new count.

Updating State Based on the Previous State

Sometimes, the new state value needs to depend on the previous state value. For example, when you want to update the same state multiple times in a single operation.

Directly using the current state value to calculate the next state can lead to problems in some situations because React's state updates can be asynchronous, and React might batch multiple set function calls.

Incorrect and Unexpect Example:

jsx

// Assuming count is initially 0
setCount(count + 1); // Expect count to become 1
setCount(count + 1); // Expect count to become 2, but it might still be 1

This is because both setCount calls might read the count value before it's updated (i.e., initial value 0).

To solve this, the set function (like setCount) can accept a function as an argument. This function receives the previous state as its first argument and returns the new state.

Correct Way, Using the Functional Update Form:

jsx

setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);

This way, React ensures that prevCount is always the latest state value at the time of executing that update, thus guaranteeing the accuracy of the state update.

Example: Safely Incrementing the Count Twice

jsx

import React, { useState } from 'react';

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

  const handleIncrementTwice = () => {
    setCount(prevCount => prevCount + 1);
    setCount(prevCount => prevCount + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrementTwice}>Increment Twice</button>
    </div>
  );
}
export default SafeCounter;

Now, after clicking the button, count will correctly increment by 2.

When to Use the Functional Update Form?

  • When the new state depends on the previous state.
  • When you call the state update function multiple times in an event handler or effect.

The Importance of Immutability

When updating object or array states in React, a core principle is immutability. This means you should never directly modify (mutate) the original state object or array. Instead, you should create a new copy of the object or array, make your modifications on this copy, and then set this new copy as the new state.

The benefits of doing this are profound and primarily manifest in the following areas:

1. Change Detection

React needs to know when the state has changed to decide whether to re-render the component. For objects and arrays in JavaScript (which are reference types), React uses shallow comparison by default to detect changes.

  • Reference Equality: When comparing two objects or arrays, React checks if they point to the same address in memory (i.e., if their references are the same).

    • If you directly mutate the state:

      jsx

      // Assume this is your state
      const [user, setUser] = useState({ name: 'John', age: 30 });
      
      function updateAgeIncorrectly() {
        const currentUser = user; // currentUser and user point to the same object
        currentUser.age = 31;    // Directly modified the original object
        setUser(currentUser);    // Still passing the same object reference
      }

      In this case, even though the age property of the user object has indeed changed, currentUser and the user before the update are still the same object reference. When React performs a shallow comparison, it will think, "Oh, the memory address of this object hasn't changed, so the state hasn't changed," and thus may not trigger a re-render. This can lead to the UI being inconsistent with the actual data.

    • If you follow immutability (create a new object):

      jsx

      function updateAgeCorrectly() {
        setUser(prevUser => ({
          ...prevUser,  // Create a new copy of prevUser
          age: 31        // Modify age on the new copy
        }));
      }

      Here, { ...prevUser, age: 31 } creates a brand new object. When React compares the new and old states, it will find that their memory addresses are different (different references), so it knows the state has indeed changed, thereby triggering a component re-render.

    Why does React use shallow comparison by default? Deep comparison of all properties of an object or array (including nested properties) is very performance-intensive, especially in large applications or frequently updated scenarios. Shallow comparison is much faster. By enforcing immutability, React can safely rely on shallow comparison to efficiently detect changes.

2. Traceability and Debugging

When you follow the principle of immutability, every state update produces a brand new state object or array. This brings the following benefits:

  • Clear Change History: You can think of each state update as a new "snapshot." If you need to track how the state has evolved over time, or where a bug was introduced, having these independent snapshots is very helpful. You can compare state objects at different points in time and clearly see what has changed.
  • Simplified Debugging:
    • When a bug occurs, if you directly modify the state, it's hard to determine which piece of code modified which property and when, because multiple parts of the code might hold a reference to the same object.
    • With immutability, each state is independent. You can more easily locate the specific setState call that caused the state change and check if the new state value passed in is as expected.
  • Better Integration with Developer Tools: Tools like React Developer Tools can better display the history of state changes because they can easily compare state snapshots from different render cycles. Advanced features like time-travel debugging also rely on state immutability.

3. Performance Optimization

Immutability is the foundation for many React performance optimization techniques:

  • React.memo and PureComponent:
    • React.memo is a Higher Order Component for function components. PureComponent is a base class for class components.
    • They both perform a shallow comparison of incoming props. If the props haven't changed, they skip re-rendering the component, thereby improving performance.
    • If you pass objects or arrays as props to these components and directly modify these objects or arrays in the parent component, then even if their content might not have actually changed (or only partially changed), their references remain the same. This can cause React.memo or PureComponent to incorrectly assume the props haven't changed, thus skipping a necessary render.
    • Conversely, if you always create new objects/arrays as props, then the reference will only change when the data truly changes, allowing React.memo and PureComponent to work correctly.
  • Optimizing shouldComponentUpdate (class components) or useMemo/useCallback (function components):
    • In custom performance optimization logic, you often need to compare previous and current props or state. If the data is immutable, you can safely perform reference comparisons, which are much faster than deep comparisons.
    • For example, in useMemo, if the values in the dependency array are immutable, React can quickly determine if the dependencies have changed by comparing references, thereby deciding whether to recompute the memoized value.

In summary, immutability is not just a coding style; it's a core part of React's operational mechanism and performance optimization. It enables React to efficiently and reliably detect state changes, ensuring correct UI updates; makes the state change process easier to track and understand, thereby simplifying debugging; and provides React with the foundation for implementing important performance optimizations. While it might initially feel a bit cumbersome to create new objects/arrays for every update, in the long run, adhering to the principle of immutability will make your React applications more robust, predictable, and efficient.

Updating Object and Array States

Updating Object State

When updating object state, the spread operator (...) is typically used to create a shallow copy of the object, and then the desired properties are modified.

Example: Updating User Information

jsx

import React, { useState } from 'react';

function UserProfile() {
  const [user, setUser] = useState({
    id: 1,
    username: 'guest',
    email: 'guest@example.com',
    preferences: {
      theme: 'light',
      notifications: true
    }
  });

  const updateUsername = (newUsername) => {
    setUser(prevUser => ({
      ...prevUser, // Copy all properties of prevUser
      username: newUsername // Update the username property
    }));
  };

  const updateTheme = (newTheme) => {
    setUser(prevUser => ({
      ...prevUser, // Copy top-level properties
      preferences: { // For nested objects, also need to spread
        ...prevUser.preferences, // Copy all properties of the preferences object
        theme: newTheme // Update the theme property
      }
    }));
  };

  return (
    <div>
      <p>Username: {user.username}</p>
      <p>Theme: {user.preferences.theme}</p>
      <button onClick={() => updateUsername('superuser')}>Update Username</button>
      <button onClick={() => updateTheme('dark')}>Switch to Dark Theme</button>
    </div>
  );
}
export default UserProfile;

In updateUsername, ...prevUser creates a new copy of the user object, and then we overwrite the username property with the new value.

For nested objects (like preferences), you need to use the spread operator at each level to ensure you don't lose other properties at that level. As shown in updateTheme, we first spread prevUser, and then spread prevUser.preferences to update the theme.

Updating Array State

When updating array state, similarly avoid directly modifying the original array. Use array methods that return a new array, such as:

  • map(): Iterates over an array and creates a new array based on the results of the callback function.
  • filter(): Filters elements based on a condition and creates a new array.
  • concat(): Joins two or more arrays and returns a new array.
  • slice(): Extracts a portion of an array and returns a new array.
  • Spread operator (...): Spreads array elements into a new array.

Avoid using methods that directly modify the original array, such as push(), pop(), splice(), sort() (unless you copy the array first).

Example: To Do List

jsx

import React, { useState } from 'react';

let nextId = 0;

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [inputValue, setInputValue] = useState('');

  const handleAddTodo = () => {
    if (inputValue.trim() === '') return;
    setTodos(prevTodos => [
      ...prevTodos, // Spread old todos
      { id: nextId++, text: inputValue, completed: false } // Add new todo
    ]);
    setInputValue('');
  };

  const handleToggleTodo = (id) => {
    setTodos(prevTodos =>
      prevTodos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const handleDeleteTodo = (id) => {
    setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
  };

  return (
    <div>
      <input
        type="text"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder="Add a new todo"
      />
      <button onClick={handleAddTodo}>Add</button>
      <ul>
        {todos.map(todo => (
          <li
            key={todo.id}
            style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
          >
            <span onClick={() => handleToggleTodo(todo.id)} style={{ cursor: 'pointer' }}>
              {todo.text}
            </span>
            <button onClick={() => handleDeleteTodo(todo.id)} style={{ marginLeft: '10px' }}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}
export default TodoList;
  • Adding an item (handleAddTodo): Use the spread operator to add the new item to the end of the existing array, creating a new array.
  • Toggling completion status (handleToggleTodo): Use map() to iterate over the array. For the item with the matching id, create a new object and toggle its completed status; other items remain unchanged. map() returns a brand new array.
  • Deleting an item (handleDeleteTodo): Use filter() to create a new array that does not include the item with the specified id.

Best Practices for useState

To use useState more effectively and maintain code clarity and maintainability, follow these best practices:

  1. Keep State Minimal:

    • Only put data that changes over time and is necessary for rendering into state.
    • Avoid storing derived data (data that can be calculated from props or other state) in state. For example, if you have firstName and lastName in state, fullName doesn't need to be stored separately as state; it can be calculated during rendering.
  2. State Organization:

    • Multiple useState vs. Single Object:
      • If multiple state variables are unrelated or their update logic is independent, it's recommended to use multiple useState calls. This makes state updates more direct and easier to understand the role of each state.

        jsx

        const [name, setName] = useState('');
        const [age, setAge] = useState(0);
      • If a group of state variables always updates together or they are logically closely related, consider grouping them into an object. But remember to handle immutability carefully when updating object state (as mentioned before, use the spread operator).

        jsx

        const [user, setUser] = useState({ name: '', age: 0 });
        // When updating: setUser(prevUser => ({ ...prevUser, name: 'New Name' }));
      • For more complex state logic or multiple sub-value dependencies, consider using the useReducer Hook.

  3. Initialize State Correctly:

    • Always provide a meaningful initial value for the state.

    • If the initial state calculation is expensive, you can use the functional form to delay the calculation, which will only run once during the initial render:

      jsx

      const [heavyData, setHeavyData] = useState(() => {
        // Perform expensive calculation
        return computeExpensiveInitialValue();
      });
      
  4. Use Descriptive Names:

    • Choose clear and descriptive names for state variables and their updater functions. For example, [count, setCount] is more understandable than [val, setVal].
  5. Follow the Rules of Hooks:

    • Only call useState at the top level of your component. Don't call Hooks inside loops, conditions, or nested functions.
    • Only call useState from React function components or custom Hooks. Don't call them from regular JavaScript functions.
  6. Prefer Functional Updates:

    • As mentioned earlier, when the new state depends on the previous state, always use the functional update form (setState(prevState => newState)) to avoid potential issues caused by asynchronous updates.
  7. Avoid Unnecessary State:

    • If a value can be directly calculated from props or computed on the fly during rendering, it usually doesn't need to be a separate piece of state. Making it state might lead to inconsistent data sources or unnecessary complexity.

Following these best practices will help you write cleaner, more efficient, and more maintainable React components.

Summary

useState is the cornerstone of state management in React function components. Mastering its usage, especially how to update based on the previous state and how to handle objects and arrays immutably, is crucial for writing robust, predictable, and maintainable React applications.

Key Takeaways:

  • Use const [value, setValue] = useState(initialValue); to declare state.
  • When the new state depends on the old state, use the functional update form: setValue(prevState => newState).
  • When updating objects and arrays, always create new copies instead of directly modifying the original state. Utilize the spread operator (...) and non-mutating array methods. Deeply understanding and practicing immutability is key to using React state effectively.
  • Follow the best practices for useState to keep your code clean and efficient.

By adhering to these principles, you will be able to manage state in your React projects with greater confidence.

contact
contact icon
contact iconcontact iconcontact iconcontact iconcontact icon

Feel free to follow me or reach out anytime! Open to work opportunities, collaborations, and connections.

Copyright © anila. All rights reserved.