The Usages and traps of useEffect in React - NOTE & BLOG
    The Usages and traps of useEffect in React
    5 February, 2024

    The article discusses the challenges and pitfalls of using useEffect in React, emphasizing its proper application for idempotent view initialization and refresh, cautioning against non-idempotent operations to avoid inconsistencies between data and UI.

    Concerning the useEffect hook, as described in the React official documentation, it is sufficient. Unfortunately, useEffect is the most controversial API design in React. It is widely considered the most challenging and difficult-to-implement hook in actual projects, stemming from the contradiction between React's idealized design and the practical demands of development.

    In this article, based on the prevailing community viewpoint, I will discuss my personal understanding of this issue: you should avoid using useEffect, as there are often better alternatives for most use cases.

    Side Effects: useEffect

    useEffect, as the name implies, describes the side effects during the rendering of a component. To understand this hook, we must first comprehend what a side effect is.

    In functional programming, a "side effect" refers to any impact on the program's state other than the return value of a function. Functional programming emphasizes the concept of pure functions, where a pure function produces the same output for the same input conditions and has no side effects.

    For example, consider a function simulating a bank statement query, requiring only two parameters: the account and a time range. It returns all the transaction records for the specified account within that time range. If it only returns the query result without any other operation, the function is pure:

    GetBankStatement(accountId, timeRange) {
      let data = ...... // fetch the data from the database;
      return data;
    }
    

    However, the bank's database is sensitive, and for security reasons, each query request must be monitored. Thus, in addition to returning the result, the function might log information about the query, such as the queried account and date:

    GetBankStatement(accountId, timeRange) {
      let data = ...... // fetch the data from the database;
      doLog("User XXXX queried account XXXX on date XXXX within the specified date range");
      return data;
    }
    

    Here, besides returning the query result, the function secretly logs the query operation, affecting the program's logging state. This operation represents a side effect.

    In practical programming, side effects may include but are not limited to:

    1. Changing mutable data state: Modifying external variables, object properties, or array elements to alter the program state.

    2. I/O operations: Including reading/writing files, sending network requests, or performing operations in a database.

    3. Printing output or logging: Outputting information to the console or logging details to a log file.

    4. Throwing exceptions: Altering the normal program flow by raising exceptions.

    5. Modifying global variables: Changing variable values in the global scope.

    Functional programming aims to minimize or avoid side effects because they increase program complexity, reduce maintainability, and can lead to unpredictable behavior. Pure functions (functions without side effects) are easier to test, compose, and reason about.

    By adhering to the principles of functional programming, code can be better controlled, readability can be improved, errors can be reduced, and programs can be more easily executed concurrently. In functional programming, it is generally encouraged to isolate side effects in specific areas of the program, such as using pure functions and the Single Responsibility Principle. If side-effect operations are needed, they can be handled at the edge of the program rather than in the core business logic.

    Let's start with functional programming.

    React, as a library for describing UIs, initially embraced some functional programming concepts. It uses JSX to encapsulate page elements into components with attributes. Given the input attributes, a component processes them logically and renders them as UI. This is similar to the mapping relationship between attributes and UI outputs in a function. In mathematical language, a function is a description of a mapping relationship: for a given, definite input, there is always a specific, unique output. This is also the requirement for functions in functional programming.

    This is the ideal React component.

    However, the problem is that although React borrows functional ideas, most components are not ideal components because each component may have its own state (State). States change with the internal logic of the component. According to the above definition, these changes are also considered side effects. Therefore, React does not strictly follow the idea of functions to describe the mapping relationship between input attributes and UI outputs.

    Two Main Use Cases

    The React official documentation shows 10 examples to illustrate the use cases of useEffect. In my view, their use cases can be essentially divided into two main categories: initializing components and refreshing components.

    Initializing Components

    Let's consider an example of developing an online bookstore app. We have a product book list displaying basic information such as book title, author, and price.

    function BooksList() {
      const [listData, setListData] = useState([]);
      return (
        <ol>
          {listData.map((item, index) => (
            <li>
              <h2>{item.title}</h2>
              <p>{item.author}</p>
              <p>{item.price}</p>
            </li>
          ))}
        </ol>
      );
    }
    

    When the app starts, this component is mounted, and its initial data state listData is an empty list, so it doesn't render anything.

    Now, if I want the app to push the latest book list to the user upon launch, I need to fetch data from the server, initialize the component, and provide the fetched data as the initial data value for listData.

    Here, useEffect provides a way to execute side effects only after the component is first mounted: by setting the dependency array to an empty array. Let's modify the code:

    function BooksList() {
      const [listData, setListData] = useState([]);
    
      useEffect(() => {
        fetch('https://my-api.com/get-latest-books')
          .then((res) => {
            setListData([...res.json()]);
          });
      }, []);
      // Here, the effect runs only once after the component is first mounted,
      // fetching data from the server, setting it to the state, and rendering it.
      // This way, the component gets an initial value, completing its initialization.
    
      return (
        <ol>
          {listData.map((item, index) => (
            <li>
              <h2>{item.title}</h2>
              <p>{item.author}</p>
              <p>{item.price}</p>
            </li>
          ))}
        </ol>
      );
    }
    

    Now, when BooksList is mounted, it executes the fetch operation to get initial data, updates listData, and renders the initial data, completing the component initialization.

    Now, let's say I want to provide feedback to the user while the data is loading. I want to display a "Loading..." message and a loading animation during the fetch operation, and then display the list once the data is loaded. How do I achieve this?

    We'll add another state, isLoading, to indicate whether the list is currently loading. If it is loading, show the loading animation and text. After loading, display the list and unset the loading state.

    So, the code is modified as follows:

    function BooksList() {
      const [listData, setListData] = useState([]);
      const [isLoading, setIsLoading] = useState(false);
    
      useEffect(() => {
        setIsLoading(true);
        fetch('https://my-api.com/get-latest-books')
          .then((res) => {
            setListData([...res.json()]);
            setIsLoading(false);
          });
      }, []);
    
      return (
        <>
          {isLoading ?
            <div>
              <LoadingAnimation />
              <p>{"Loading..."}</p>
            </div>
            :
            <ol>
              {listData.map((item, index) => (
                <li>
                  <h2>{item.title}</h2>
                  <p>{item.author}</p>
                  <p>{item.price}</p>
                </li>
              ))}
            </ol>
          }
        </>
      );
    }
    

    In this example, fetch and setIsLoading are part of the effect executed when the component is mounted and rendered. Here, the effect provides an indication of the loading logic during the initial rendering of the component.

    Accompanying Component State Refresh

    A commonly encountered scenario is pagination.

    For instance, I have a book list page, simplified as follows:

    export default function BooksListPage() {
    
      const [listData, setListData] = useState([]);
      const [currentPage, setCurrentPage] = useState(1);
    
      const handleLoadListData = (pageNum: number) => {
        fetch(`https://my-api.com/books-list/${pageNum}`)
          .then((res) => {
            setListData([...res.json()])
          })
      }
    
      useEffect(() => {
        handleLoadListData(currentPage);
      }, [])
    
      return (
        <main>
          <BooksList data={listData} />
          <p>{`Current Page: ${pageNum}`}</p>
          <button
            onClick={() => setCurrentPage(currentPage - 1)}
          >
            {"Previous Page"}
          </button>
          <button
            onClick={() => setCurrentPage(currentPage + 1)}
          >
            {"Next Page"}
          </button>
        </main>
      )
    }
    

    The above code represents pagination for the BooksList component. It includes a currentPage to indicate the current page number. Based on this page number, the API is called to load the corresponding list. Now that the initialization of the component is done, how do we implement loading the list corresponding to the new page number when the "Previous Page" or "Next Page" button is clicked?

    Firstly, note that we are changing the currentPage state, and loading the corresponding list is an operation influenced by the changed state, modifying the listData state at the same time. In essence, loading the corresponding list is an effect.

    Therefore, we can set up a useEffect with a loading new page list effect. The condition for this effect to take place is a change in currentPage.

    export default function BooksListPage() {
    
      const [listData, setListData] = useState([]);
      const [currentPage, setCurrentPage] = useState(1);
    
      const handleLoadListData = (pageNum: number) => {
        fetch(`https://my-api.com/books-list/${pageNum}`)
          .then((res) => {
            setListData([...res.json()])
          })
      }
    
      useEffect(() => {
        handleLoadListData(currentPage);
      }, [])
    
      useEffect(() => {
        handleLoadListData(currentPage)
      }, [currentPage])
    
      return (
        <main>
          <BooksList data={listData} />
          <p>{`Current Page: ${pageNum}`}</p>
          <button
            onClick={() => setCurrentPage(currentPage - 1)}
          >
            {"Previous Page"}
          </button>
          <button
            onClick={() => setCurrentPage(currentPage + 1)}
          >
            {"Next Page"}
          </button>
        </main>
      )
    }
    

    Now, when the currentPage state changes, it automatically executes the effect to load the corresponding list. This achieves the refreshing of the component's state.

    Drawbacks

    Both of the above use cases have a strict prerequisite: effect operations must be idempotent. Idempotence means that for an operation, the initial operation and subsequent operations must be entirely equivalent.

    For example, in database operations, read (GET), search, delete, and update (PUT, POST, DELETE) are idempotent operations. When we query data from the database, the database's state does not change. The first query for specific data and subsequent queries for the same data yield equivalent results. Therefore, querying data is an idempotent operation.

    However, when we perform create (POST), delete (DELETE), or update (PUT) operations on data, the state of the affected data changes: new data is added, existing data is deleted, or existing data is modified. These operations are non-idempotent.

    This is where the dilemma of useEffect lies.

    React, initially designed as a library for declarative UI construction, primarily focuses on the relationship between input data flow, state data flow, and output UI. It relies on the unidirectional data flow of input → state → output to produce the final result.

    Since the input data flow is dynamic and can change at any time, and states can change at any time based on internal business logic, and states' uncertainty determines the uncertainty of UI rendering results:

    Note: For performance reasons, even after updating the state to the latest data using setState, React may not immediately render the UI based on the latest state. It waits to collect enough latest states before batch updating and rendering. For more details, refer to the official documentation.

    So, React cannot accurately determine when a component will be re-rendered based on state changes and cannot predict how many times it will be re-rendered. In other words, you cannot precisely anticipate the next step after an operation, whether the component will be refreshed or re-rendered.

    Therefore, if you treat a non-idempotent operation as an effect, it can lead to inconsistencies between data and UI

    .

    On Twitter and GitHub, developers often complain that in debug mode, useEffect is executed twice, leading to bugs. In reality, they completely misunderstand the actual purpose of useEffect and incorrectly use it as an event listener for state changes.

    Simply remembering this rule can help avoid misusing useEffect:

    The effect of useEffect should only be used for idempotent view initialization and view refresh. Beyond this, it has no other purpose and should not be used for any business logic.

    Although this statement might sound absolute and somewhat harsh, it can fundamentally prevent misuse of useEffect.

    For example, if you use a non-idempotent operation as an effect:

    function BooksManager() {
      const [listData, setListData] = useState([]);
    
      const handleAddNewBook = (book) => {
        setListData((prevListData) => [book, ...prevListData]);
      };
    
      useEffect(() => {
        axios.post("https://my-api.com/add-book", {
          body: listData[0],
        });
      });
    
      // ....
    }
    

    What's wrong with the above code?

    It attempts to immediately sync the added book to the server after adding a new item to the data list. However, "Adding a new book" itself is not an idempotent operation. This effect not only triggers after the listData state changes but also after the parent component re-renders, with an unpredictable number of triggers.

    Now, let's add a dependency array to this useEffect, restricting it to trigger only after listData changes. How about this?

    useEffect(() => {
      axios.post("https://my-api.com/add-book", {
        body: listData[0],
      });
    }, [listData]);
    

    This approach is very risky.

    Because once I execute

    setListData([...listData]);
    

    The data table itself has not changed, and there is no new book added. Even though the comparison of various rendering nodes in the Diff process has not changed, the reference to the array in the state data has changed. It might still trigger the effect, causing the repetitive addition of existing books.

    Point of No Return

    After all that discussion, let me share my personal perspective on useEffect. Unfortunately, useEffect is a fundamentally flawed and irreparable mistake. Numerous bugs and accidents have resulted from useEffect, and it has become the cause of various issues.

    There is a well-known incident on Twitter's web platform where developers forgot to add a dependency array to useEffect, leading to a massive number of GET requests, eventually crashing Twitter's servers. Initially, the cause of this bug was not identified, and the event of Twitter's service overload was mistakenly attributed to a network crawler attack launched by competitors. As a response, Elon Musk set a daily browsing limit for Twitter, resulting in an unintentional incident.

    Due to its roots in React's core design philosophy, this kind of error is irreversible.

    I once came across an answer on Zhihu (Chinese Q&A platform) that pinpointed the problem well:

    The essence of GUI is a state machine with a lifecycle. It is the result of the interaction between state data and the view, not a simple F(x).

    The writing style of useEffect in React is indeed awkward, and it made many fundamental mistakes from the beginning. Various optimization techniques and hooks introduced later are just patching up these issues.

    For instance, the uncertainty of states leads to the uncertainty of rendering, sometimes causing redundant rendering, and developers have to use methods like useMemo, useCallback, React.memo to avoid redundant rendering. All optimizations must be done manually, adding to the cognitive burden and making React's performance rank lower compared to other frameworks.

    Frameworks like Vue do not have these issues. Vue's performance is even better than React.

    However, React, despite its many pitfalls in this regard, is still considered an excellent front-end framework after years of development. When learning React, it is crucial to learn from these "pitfalls" and avoid such practices, rather than getting bogged down in the details of peculiarities in writing.

    As one wise developer summarized:

    Many people mentioned the most critical point below: the state in asynchronous side effects should not be out of sync, useEffect is not a lifecycle, not didUpdate. Dependencies are just a helpless move limited by the nature of the JavaScript language, and it's not a feature. Therefore, no business logic should depend on dependencies to be implemented.

    凡有所学,皆成性格

    Share with the post url and description