React

React is a JavaScript library open-sourced by Facebook that can be used to build UI on any platform. A common pattern in React is to use useEffect with useState to send requests, sync state from the API (outside React) to inside React, and use it to render UI, and this article shows you exactly why you shouldn’t do that directly.

TL; DR

  • Most of the reasons for triggering network requests are user actions, and network requests should be sent in Event Handler.
  • Most of the time, the data needed for the first screen can be rendered SSR-direct from the server, without sending additional network requests on the client side.
  • Even if the client needs to fetch data on the first screen, in the future React and community-maintained libraries will provide Suspense-based data request patterns that implement “Render as your fetch”.
  • Even when using the “Fetch on render” pattern, you should use a third-party library such as SWR or React Query directly instead of using useEffect.

Start by sending a simple request

Imagine you’re writing a React application that needs to get product listing data from the API and render it to the page. You think about the fact that network requests are not rendering, but side effects of rendering, and you think about the fact that React provides a special Hook useEffect for handling side effects of rendering, most often in the scenario of synchronizing state that is external to React to React internally. Without thinking about it, you implement a <ProductList /> component.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const ProductList = () => {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch('https://dummyjson.com/products')
      .then(res => res.json())
      .then(data => setProducts(data));
  }, []);

  return (
    <ul>
      {products.map(product => (
        <Product {...product} key={product.id} />
      ))}
    </ul>
  );
}

You run npm run dev and with a sense of accomplishment you see the list of products displayed on the page.

Show “Loading” and errors in UI

You find that when you first load, the page is white until the data is loaded, which is a bad user experience. So you decide to implement a “loading” progress bar and introduce a new state isLoading.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const ProductList = () => {
  const [isLoading, setIsLoading] = useState(true);
  const [products, setProducts] = useState([]);

  useEffect(() => {
    setIsLoading(true);
    fetch('https://dummyjson.com/products')
      .then(res => res.json())
      .then(data => {
        setProducts(data);
        setIsLoading(false);
      });
  }, []);

  if (isLoading) {
    {/* TODO 实现一个骨架屏 <Skeleton /> 改善 UX、避免 CLS */}
    return <Loading>正在玩命加载中...</Loading>;
  }

  return (
    <ul>
      {products.map(product => (
        <Product {...product} key={product.id} />
      ))}
    </ul>
  );
}

Then you realize that in addition to a “loading” status, you need to display error alerts and report error logs if necessary, so you introduce a new status error.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const ProductList = () => {
  const [isLoading, setIsLoading] = useState(true);
  const [products, setProducts] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    setIsLoading(true);
    fetch('https://dummyjson.com/products')
      .then(res => res.json())
      .then(data => {
        setProducts(data);
        setIsLoading(false);
      })
      .catch(err => {
        // TODO 错误日志上报
        setError(err)
      });
  }, []);

  if (isLoading) {
    {/* TODO 实现一个骨架屏 <Skeleton /> 改善 UX、避免 CLS */}
    return <Loading>正在玩命加载中...</Loading>;
  }

  if (error) {
    {/* TODO 添加「重试」按钮 */}
    return <div>出现错误啦</div>
  }

  return (
    <ul>
      {products.map(product => (
        <Product {...product} key={product.id} />
      ))}
    </ul>
  );
}

Wrapping a new Hook

You find it cumbersome to repeat the above code for every component that needs to fetch data from the API. So you decide to wrap it into a useFetch Hook that you can call directly from within the component.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const useFetch = (url, requestInit = {}) => {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    setIsLoading(true);
    fetch(url, requestInit)
      .then(res => res.json())
      .then(data => {
        setData(data);
        setIsLoading(false);
      })
      .catch(err => setError(err));
  }, [url, requestInit]);

  return { data, isLoading, error };
}

Now you can use the useFetch Hook directly in the component.

1
2
3
4
5
6
7
const ProductList = () => {
  const { isLoading, data, error } = useFetch('https://dummyjson.com/products');
}

const Product = ({ id }) => {
  const { isLoading, data, error } = useFetch(`https://dummyjson.com/products/${id}`);
}

Handling Race Condition

You implement a rotating component that switches between multiple products, with the currently displayed product stored in the state curentProduct.

1
2
3
4
const Carousel = ({ intialProductId }) => {
  const [currentProduct, setCurrentProduct] = useState(intialProductId);
  const { data, isLoading, error } = useFetch(`https://dummyjson.com/products/${currentProduct}`);
};

As a result, when you test it, you find that when you switch quickly in the rotating component, sometimes when you click on the next product, the interface shows the previous one.

This is because you didn’t declare how to clear your side effects in useEffect. Sending a network request is an asynchronous behavior and the order in which the server data is received is not necessarily the order in which the network request is sent, Race Condition occurs.

1
2
| =============== Request Product 1 ===============> | setState()
      | ===== Request Product 2 ====> | setState() |

If the second product’s data is returned faster than the first product as shown above, your data will be overwritten by the first product’s data.

So you write a logic in useFetch to clear the side effects.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const useFetch = (url, requestInit = {}) => {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isCancelled = false;

    setIsLoading(true);
    fetch(url, requestInit)
      .then(res => res.json())
      .then(data => {
        if (!isCancelled) {
          setData(data);
          setIsLoading(false);
        }
      })
      .catch(err => {
        if (!isCancelled) {
          setError(err);
          setIsLoading(false);
        }
      });

    return () => {
      isCancelled = true;
      setIsLoading(false);
    }
  }, [url, requestInit]);

  return { data, isLoading, error };
}

Thanks to the power of JavaScript closures, Product 2 data will now not overwrite Product 2 data even if Product 2 data is returned before Product 1 data.

You can also check if the current browser supports AbortController when clearing side effects, and use AbortSignal to cancel aborted network requests.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const isAbortControllerSupported = typeof AbortController !== 'undefined';

const useFetch = (url, requestInit = {}) => {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isCancelled = false;
    let abortController = null;
    if (isAbortControllerSupported) {
      abortController = new AbortController();
    }

    setIsLoading(true);
    fetch(url, { signal: abortController?.signal, ...requestInit })
      // .then(...

    return () => {
      isCancelled = true;
      abortController?.abort();
      setIsLoading(false);
    }
  }, [url, requestInit]);

  return { data, isLoading, error };
}

Caching network requests

Let’s go back to the above rotating component.

Whenever the rotating component switches, <Product /> receives a new props.id, the component undergoes an update, the url changes, useEffect is re-executed, and a new network request is triggered. To remove subsequent unnecessary network requests, useFetch needs a cache.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const isAbortControllerSupported = typeof AbortController !== 'undefined';
/** TODO 将 RequestInit 对象也存在缓存里 */
const cache = new Map();

const useFetch = (url, requestInit = {}) => {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isCancelled = false;
    let abortController = null;
    if (isAbortControllerSupported) {
      abortController = new AbortController();
    }

    if (cache.has(url)) {
      setData(cache.get(url));
      setIsLoading(false);
    } else {
      setIsLoading(true);
      fetch(url, { signal: abortController?.signal, ...requestInit })
        .then(res => res.json())
        .then(data => {
          if (!isCancelled) {
            cache.set(url, data);
            setData(data);
            setIsLoading(false);
          }
        })
        .catch(err => {
          if (!isCancelled) {
            setError(err);
            setIsLoading(false);
          }
        });
    }

    return () => {
      isCancelled = true;
      abortController?.abort();
      setIsLoading(false);
    }
  }, [url, requestInit]);

  return { data, isLoading, error };
}

Are you starting to get a bit of a headache? Don’t fret, we’re just getting started.

Cache Refresh

There are 2 hard problems in computer science: naming things, cache invalidation, and off-by-1 errors.

With caching, you need to refresh the cache, otherwise your data displayed on the UI may be out of date. There are many times when you can refresh the cache, for example you can refresh the cache when a tab loses Focus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const isAbortControllerSupported = typeof AbortController !== 'undefined';
const cache = new Map();
const isSupportFocus = typeof document !== 'undefined' && typeof document.hasFocus === 'function';

const useFetch = (url, requestInit = {}) => {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  const removeCache = useCallback(() => {
    cache.delete(url);
  }, [url]);
  const revalidate = useCallback(() => {
    // TODO 重新 fetch 获取数据填充缓存
  } []);

  useEffect(() => {
    const onBlur = () => {
      removeCache();
    };

    const onFocus = () => {
      revalidate();
    };

    window.addEventListener('focus', onFocus);
    window.addEventListener('blur', onBlur);

    return () => {
      window.removeEventListener('focus', onFocus);
      window.removeEventListener('blur', onBlur);
    };
  })

  // fetch 相关逻辑
  // useEffect(() => ...

  return { data, isLoading, error };
}

You can also update the cache regularly and repeatedly (interval), and you can update the cache when the user’s network state changes (when switching from data traffic to Wi-Fi). Now you need to write more useEffects and addEventListeners.

Also, when the component is unmounted and remounted, you can first use the cache to render the interface to avoid another white screen, but then you need to asynchronously refresh the cache and finally update the latest data to the UI again.

Compatible with React 18 Concurrent Rendering

React 18 introduces the concept of Concurrent Rendering. In short, when opt-in Concurrent Rendering, React can interrupt, pause, or even abort updates marked as “low priority” (like Transitions) to make way for “high priority” updates.

When implementing the useFetch cache, cache is a global variable and every useFetch in every component can read and write to cache directly. Although the data obtained when cache.get is up to date, after a useFetch calls cache.set, the cache cannot notify other useFetchs that it needs to be updated and has to passively wait for the next cache.get from another useFetch.

Suppose your <ProductList /> component uses React 18’s Concurrent API, such as useTransition or startTransition, and both <ProductList /> and <Carousel /> use useFetch(' https://dummyjson.com/products') to get data from the same API. Since the <ProductList> component opts in to Concurrent Rendering, rendering and updating of <ProductList /> and <Carousel /> may not necessarily happen at the same time (React may pause < ProductList /> in response to the user’s interaction with <Carousel />, i.e. the updates of the two components are not synchronized), and the cache of useFetch may have been refreshed and changed between the two updates, resulting in <ProductList /> and <Carousel /> using different cached data for their respective useFetchs, resulting in Tearing.

To avoid Tearing, notify React of global variable updates, and schedule re-rendering, you need to re-implement cache to use React 18’s other Hook useSyncExternalStore.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
const cache = {
  __internalStore: new Map(),
  __listeners: new Set(),
  set(key) {
    this.__internalStore.set(key);
    this.__listeners.forEach(listener => listener());
  },
  delete(key) {
    this.__internalStore.delete(key);
    this.__listeners.forEach(listener => listener());
  },
  subscribe(listener) {
    this.__listeners.add(listener);
    return () => this.__listeners.delete(listener);
  },
  getSnapshot() {
    return this.__internalStore;
  }
}

const useFetch = (url, requestInit) => {
  const currentCache = useSyncExternalStore(
    cache.subscribe,
    useCallback(() => cache.getSnapshot().get(url), [url])
  );

  // 缓存刷新逻辑
  // useEffect(() => ...

  useEffect(() => {
    let isCancelled = false;
    let abortController = null;
    if (isAbortControllerSupported) {
      abortController = new AbortController();
    }

    // 不再直接 `cache.get`,而是读取通过 useSyncExternalStore 获取到的 currentCache
    // TODO:理想中应该直接将 currentCache 视为 data,而不是将 currentCache 同步到 data 中
    if (currentCache) {
      setData(localCache);
      setIsLoading(false);
    } else {
      setIsLoading(true);
      fetch(url, { signal: abortController?.signal, ...requestInit })
        .then(res => res.json())
        .then(data => {
          if (!isCancelled) {
            // 写入 cache 时,直接使用 cache.set
            cache.set(url, data);
            setData(data);
            setIsLoading(false);
          }
        })
        .catch(err => {
          // if (!isCancelled) ...
        });
    }

    return () => {
      isCancelled = true;
      abortController?.abort();
      setIsLoading(false);
    }
  }, [url, requestInit]);
}

Now, whenever a useFetch is written to the cache, React will update all components that used the useFetch with the latest value in the cache.

Feeling light-headed? Fasten your seat belt and let’s continue.

Request Merge De-duplication

There are two hard things in computer science: cache invalidation, naming things, and off-by-one errors. Oh and weird concurrency bugs. Oh and weird concurrency bugs.

Your React app might look like this.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<Layout>
  <Carousel list={hotProductLists}>
    {
      (productId) => <Product id={productId} />
    }
  </Carousel>
  <ProductList>
    {allProductLists.map(product => <Product key={product.id} id={product.id} />)}
  </ProductList>
</Layout>

Since you are Fetching on render in the <Product /> component, there may be more than one <Product id={114514} /> in your React Tree at the same time; so you may still be sending more than one request to the same URL at the same time when the page is first loaded and not cached. To merge the same requests, you need to implement a mutex lock to avoid multiple useFetchs sending multiple requests to the same URL; then you also need to implement a pub/sub to broadcast the API response data to all useFetchs using this URL.

Do you think it’s over? No.

More

As a low-level React Hook for sending web requests, useFetch will need to do more than that.

  • Error Retry: Conditional retries when data loading goes wrong (e.g. retries on 5xx only, retries on 403, 404 abandoned)
  • Preload: Preload data to avoid waterfall requests
  • SSR, SSG: the data obtained by the server is used to fill the cache in advance, render the page, and then refresh the cache on the client side
  • Pagination: large amount of data, paging requests
  • Mutation: respond to user input, send data to the server
  • Optimistic Mutation: Update local UI when user submits input, creating the illusion of “successful modification”, while sending input to the server asynchronously; if there is an error, the local UI needs to be rolled back.
  • Middleware: Logging, error reporting, Authentication

With so many requirements for a useFetch, why not just use an off-the-shelf React Data Fetching Hook? Both SWR and React Query can override these functions.

Reference

  • https://blog.skk.moe/post/why-you-should-not-fetch-data-directly-in-use-effect/