前言

前陣子在公司開發一個搜尋網站,參考第三方開源專案 searchkit 的設計架構,嘗試使用 GraphQL 搭配 Elasticsearch 來透過一次 request 獲取大量 resource 的搜尋資料,前端則使用 React 搭建 SPA 網頁,但在使用頁籤跳頁時卻出現 React 的警告:

Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

大概知道是 useEffect 要 cleanup 的問題,但卻不知道 callback funtion 該怎麼做,debug 了好一陣子也未果,後來因緣際會下看到幾篇技術文章才恍然大悟,原來 fetch 就有 abort 可以處理,因此簡單以 pagination 的情境來重現上述問題,順手作個紀錄

重現問題

為了重現當初遇到的問題,找了 jsonplaceholder 上的 open data,來做一個文章(posts) pagination 的 demo

問題癥結點:一言以蔽之,以下是等等要實做的 pagination 陽春的樣貌,有兩個按鈕分別切換上下頁,點擊後每次都會去向後端 query 一次該頁的文章,當頻繁地切換上下頁時就會出現上述的錯誤

client_app

先把資料給載下來

curl https://jsonplaceholder.typicode.com/posts > posts.json
  • Server: Node.js Express
  • Client: React 18

server

簡單開一個 offset-based 的 pagination 的 API,以利等等前端來呼叫,並提供 fromsize 兩個 query parameter 來指定從哪一頁開始,一頁多少個數量

// server.ts

import express, { Request, Response } from "express";
import cors from "cors";
import posts from "./posts.json";

const app = express();
app.use(
  cors({
    origin: ["http://localhost:3000"],
  })
);

const PORT = 8000;

// Offset-based
app.get("/posts", (req: Request, res: Response) => {
  const from = req.query.from ? Number(req.query.from) : 1;
  const size = req.query.size ? Number(req.query.size) : 10;

  // 延遲2秒較好demo
  setTimeout(() => {
    res.json(posts.slice(from - 1, from + size));
  }, 2000);
});

app.listen(PORT, () => {
  console.log(`Server is running at ${PORT}`);
});

Client

前端用 CRA 建立 React App,每當pageCount這個 state 改變後,就去跟後端 fetch 一次該頁面的文章,並更新 Post 這個 component 內的文章

// Posts.tsx

import React, { useEffect, useState } from "react";
import "./App.css";
import axios from "axios";

interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

function Post({ posts }: { posts: Post[] }) {
  return (
    <div>
      {posts.length === 0 ? (
        <div>Loading</div>
      ) : (
        posts.map((post, idx) => {
          return (
            <div key={idx} style={{ margin: 12 }}>
              <div>{post.id}</div>
              <div>{post.body}</div>
              <hr />
            </div>
          );
        })
      )}
    </div>
  );
}

function PostList() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [pageCount, setPageCount] = useState<number>(0);

  useEffect(() => {
    const size = 10;

    fetch(
      `http://localhost:8000/posts?from=${pageCount * size + 1}&size=${size}`
    )
      .then((res) => res.json())
      .then((posts) => {
        setPosts(posts);
      })
      .catch((err) => {
        console.error(err);
      });
  }, [pageCount]);

  const goToLastPage = () => {
    setPageCount((pageCount) => pageCount - 1);
  };

  const goToNextPage = () => {
    setPageCount((pageCount) => pageCount + 1);
  };

  return (
    <div>
      <button onClick={goToLastPage}>Last Page</button>
      <button onClick={goToNextPage}>Next Page</button>
      {<Post posts={posts} />}
    </div>
  );
}

export default PostList;

因為後端的 response 被我故意延遲兩秒,所以前端在連續快速點擊下一頁或上一頁的時候,就會出現以下錯誤:

Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

(以上錯誤是 React18 以前才會有,18 版之後就不會有錯誤警告,官方說明在此)

原因出在前端 fetch 的 request 在被 server 回傳 response 的期間,Post 這個 component 就被 unmount 了,這時候瀏覽器想渲染就會因為元件已經 unmount 而出錯。常見於切換頁面或是快速 mount、unmount 元件但他有 subscription 的情境

解決方法

解法就是 useEffect 在 subscription 時要實作 cleanup 的機制,而因為我這邊的 subscription 是去 fetch 後端的資料,所以我的 cleanup 也是從 fetch 下手,使用 AbortController 來讓 fetch 出去的 request 可以在元件被 unmount 的同時被 abort 掉

useEffect(() => {
  const size = 10;

  // Create Abort controller object and get its signal object
  const controller = new AbortController();
  const signal = controller.signal;

  // Put the signal object into the fetch request
  fetch(
    `http://localhost:8000/posts?from=${pageCount * size + 1}&size=${size}`,
    {
      signal: signal,
    }
  )
    .then((res) => res.json())
    .then((posts) => {
      setPosts(posts);
    })
    .catch((err) => {
      // Check if the error comes from AbortError
      if (err.name === "AbortError") {
        console.error(`Request is aborted`);
      } else {
        console.error(err);
      }
    });

  // Cleanup! Execute abort method which defined in the controller object
  return () => controller.abort();
}, [pageCount]);

前端開發時,除了原生的 fetch 以外,有時候也會用 axios 套件,axios 也可以 abort 發出去的 request

useEffect(() => {
  const size = 10;

  // Create the cancel token
  const source = axios.CancelToken.source();

  // Put the cancel token into the axios request
  axios
    .get(
      `http://localhost:8000/posts?from=${pageCount * size + 1}&size=${size}`,
      {
        cancelToken: source.token,
      }
    )
    .then((res) => res.data)
    .then((posts) => {
      setPosts(posts);
    })
    .catch((err) => {
      // Check if the error comes from AbortError
      if (axios.isCancel(err)) {
        console.error(`Request is aborted`);
      } else {
        console.log(err);
      }
    });

  // Cleanup! Execute cancel method which defined in axios cancel token
  return () => {
    source.cancel();
  };
}, [pageCount]);

實測之後,再次快速點擊上下頁切換,會看見我們定義的錯誤訊息: Request is aborted,可以得知當 Post 元件被 unmount 時,剛剛發送出去的 request 也會同時被 abort 拋棄掉(觀察瀏覽器的 Network 頁籤最明顯),避免快速切換時累積多筆 request,造成 response 回來時因為元件被 unmount 而無從渲染

結論

以上就是 useEffect cleanup 在訂閱 fetch 發送 request 時的情境,這個情境特別容易發生在 API-riched 的前後端架構,所以應該要好好處理以避免效能爆炸、前端渲染的衍生問題。

值得注意的是,官方文件說 React 18 以後該錯誤訊息將會移除(因為內容提及 memory leak,但往往問題都不是如此)

No warning about setState on unmounted components: Previously, React warned about memory leaks when you call setState on an unmounted component. This warning was added for subscriptions, but people primarily run into it in scenarios where setting state is fine, and workarounds make the code worse. We’ve removed this warning.

或許未來就不需要再處理這類狀況,但以現階段我使用的 React 18 版來說,即使錯誤警告的訊息移除,但問題並不會因此解決,仍需要自己增加 cleanup 機制來解決 unmount 時 subscription 的問題。

(有趣的是,這篇的實作內容其實也是某次面試 fullstack engineer 時對方當場出的考題,給我一個 app 的 code,考我這個 app 有哪邊出問題,要我找出問題並修正)