react: Bug: React.StrictMode causes AbortController to cancel

React version: 18.2

Steps To Reproduce

  1. import a hook that uses AbortController
  2. Without React.StrictMode the abort controller aborted = false
  3. With React.StrictMode the abort controller gets set to aborted = true
const useAbortController = (abortControllerProp, shouldAutoRestart = false) => {
  const abortController = useRef(abortControllerProp || initAbortController());

  useEffect(() => {
    if (shouldAutoRestart && abortController.current.signal.aborted) {
      abortController.current = initAbortController();
    }
  }, [abortController.current.signal.aborted, shouldAutoRestart]);

  useEffect(() => () => abortController.current.abort(), []);

  return abortController.current;
};

The current behavior

The “echoed” rendering of the component causes the the controller to go from aborted false -> true.

The expected behavior

I’m not sure if this is inherent to what react tests for in this mode, or something that can be expected to work.

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Comments: 15

Most upvoted comments

I have the same issue with a single abort controller being called to abort multiple API calls. Here’s my example code for easier reproduction:

import axios from 'axios'
import { useEffect, useState } from 'react'

export default function SearchComponent {
  const [aiSearches, setAISearches] = useState([])
  const [aiSearchesLoading, setAISearchesLoading] = useState(false)
  const [partnerSearches, setPartnerSearches] = useState([])
  const [partnerSearchesLoading, setPartnerSearchesLoading] = useState(false)

  const controller = new AbortController()
  const { signal } = controller

  const getAISearches = async () => {
    console.log('getAISearches API call')
    setAISearchesLoading(true)
    return axios
      .get(`/api/search/ai`, {
        signal,
      })
      .then((res) => {
        const { data } = res
        data.success && setAISearches(data.data)
      })
      .catch((err) => {
        console.log('getAISearches caught')
        console.log({ err })
      })
      .finally(() => setAISearchesLoading(false))
  }

  const getPartnerSearches = async () => {
    console.log('getPartnerSearches API call')
    setPartnerSearchesLoading(true)
    return axios
      .get('/api/search/partner', {
        signal,
      })
      .then((res) => {
        const { data } = res
        data.success && setPartnerSearches(data.data)
      })
      .catch((err) => {
        console.log('getPartnerSearches caught')
        console.log({ err })
      })
      .finally(() => setPartnerSearchesLoading(false))
  }

  useEffect(() => {
    getAISearches()
    getPartnerSearches()

    return () => {
      console.log('CONTROLLER ABORT')
      controller.abort()
    }
  }, [])

  return (
    <div>
      <div>
        {aiSearches.length ? (
          <div>
            AI Search is rendered successfully
          </div>
        ) : (
          <div>
            {aiSearchesLoading ? 'Loading...' : 'No AI search found.'}
          </div>
        )}
      </div>
      <div>
        {partnerSearches.length ? (
          <div>
            Partner Search is rendered successfully
          </div>
        ) : (
          <div>
            {partnerSearchesLoading ? 'Loading...' : 'No partner search found.'}
          </div>
        )}
      </div>
    </div>
  )
}

According to API docs, this code should not have any issues and technically fetch, abort, then fetch again the data from API. But the issue is that the aborted API call throws CancelledError even on the second attempt:

getAISearches API call
getPartnerSearches API call
CONTROLLER ABORT
getAISearches API call
getPartnerSearches API call
getAISearches caught
{err: CanceledError}
getPartnerSearches caught
{err: CanceledError}
getAISearches caught
{err: CanceledError}
getPartnerSearches caught
{err: CanceledError}

Solution to this particular issue is to create abort controller in effect, and do not use useMemo or useState for it.

But still, I consider this issue a bug, because it should not matter if we use AbortController or something else, the problem with useEffect getting wrong object still exists.

I made a custom hook for the AbortController that seems to work in strict mode

const useAbortController = () => {
  const controllerRef = useRef(new AbortController())
  const isMountedRef = useRef(false)

  useEffect(() => {
    const controller = controllerRef.current

    isMountedRef.current = true

    return () => {
      isMountedRef.current = false

      window.requestAnimationFrame(() => {
        if (!isMountedRef.current) {
          controller.abort()
        }
      })
    }
  }, [])

  return controllerRef.current
}

The issue here is that your effect’s code isn’t symmetric. You shouldn’t implement cleanup in a separate effect — the effect that creates the controller should be the same one that destroys. Then an extra cycle wouldn’t break your code.

@EdmundsEcho the recommended approach it to just let it happen and make sure it works correctly