Search code examples
javascriptreactjsfetchantdabortcontroller

React Strict Mode enabled together with AbortController in useEffect prevent setting loading state of Ant Design table


I've got a component written in React.js where I'm fetching some data via the fetch API which I then show in a Ant Design Table component. While I fetch the data, the table should have a loading state. The code can be simplified as this:

// MyComponent.tsx

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

import {Table} from 'antd'

import {MyDataType} from '../../utils/common.types'

import type {ColumnsType} from 'antd/es/table'

const MyComponent: React.FC = () => {
  const [data, setData] = useState<MyDataType[]>([])
  const [loading, setLoading] = useState<boolean>(false)

  useEffect(() => {
    const fetchData = async (abortController?: AbortController) => {
      setLoading(true)
      try {
        const response = await fetch('https://myapi.myweb.com/data', {signal: abortController?.signal})
        const fetchedData: MyDataType[] = await response.json()
        setData(fetchedData)
      } catch (e) {
        if (!abortController.signal.aborted) console.error('Fetch failed!')
      } finally {
        setLoading(false)
      }
    }

    const controller = new AbortController()
    fetchData(controller).then() // .then() just to silence linter
    return () => {
      controller.abort()
    }
  }, [])
  
  const columns: ColumnsType<MyDataType> = [
    {
      title: 'ID',
      dataIndex: 'id',
      key: 'id',
    },
    {
      title: 'Name',
      dataIndex: 'name',
      key: 'id',
      // reuse data with state
      render: (text: string, _record: MyDataType) => <Link to={`/data/${_record.id}`} state={_record}>{text}</Link>,
    },
    {
      title: 'Description',
      dataIndex: 'description',
      key: 'description',
    },
  ]

  return (
    <>
      <Table
        columns={columns}
        rowKey={(record) => record.id}
        dataSource={data}
        size="small"
        pagination={{pageSize: 50}}
        loading={loading}
      />
    </>
  )
}

export default MyComponent

I also have React Strict Mode enabled:

//main.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'

import App from './App'

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

The way it is written causes the app to not show the loading indicator in the table when the data are being fetched. When I disable the Strict Mode or remove the controller.abort() statement from the cleanup function in the useEffect hook the loading state is shown correctly.

Could someone please explain why this is happening and, possibly, advise me on how to fix the code so it works as intended?


Solution

  • This occurs because useEffect runs twice when Strict Mode is enabled, or to be more specific, useEffect runs after a component gets mounted. With Strict Mode enabled, React components get mounted, unmounted and then mounted again, effectively causing useEffect to run twice.

    After the first run, the loading state is set to false and it is expected, that the subsequent second run sets the loading state again to true. However, because the fetchData function runs asynchronically, the second useEffect run starts and sets loading to true (while it is still true) before the first run of the fetchData function finishes, and before (!) loading state will be set to false by the first run.

    So loading is set to false while the second run is fetching data, but after the second run sets loading to true.

    The reason why controller.abort() appears to mess with your loading state is that, without aborting, the first run finishes after (!) fetching data, then setting loading to false (while the second run already started, but that doesn't change the outcome) and everything seems fine as data is fetched correctly and loading is set to false by the first run.

    However, when aborting, the first run finishes much earlier, because it doesn't wait for the fetch, setting the loading state to false (finally block), while the second run already started and is fetching data. Thus while (!) the second run is fetching data the loading state is set to false, although the second run is still fetching/"loading" and there is no fetched data from the first run nor from the second run.

    A possible solution could be to set the loading state to false only when the request was not aborted by the controller. This can be determined by the abortController.signal.aborted boolean.

    } finally {
      if (!abortController.signal.aborted) setLoading(false)
    }
    

    But this should be used with caution as this can cause an infinite loading state if a request is aborted, but no second request is triggered which would set the loading state to false again.

    Another possible solution would be to discard the loading state and use an explicit error state. Loading would be determined by deriving it from the availability of the fetched data and the absence of an error.

    const [data, setData] = useState<MyDataType[]>([])
    const [hasError, setHasError] = useState(false)
    //...
    
    // using data.length here to determine if data has been fetched correctly
    const isLoading = !data.length && !hasError
    return (
        <>
          <Table
            columns={columns}
            rowKey={(record) => record.id}
            dataSource={data}
            size="small"
            pagination={{pageSize: 50}}
            loading={isLoading}
          />
        </>
      )
    

    Initially data (or in this case data.length) is falsy, and hasError is false, so isLoading will be true. As soon as one of the state variables (data or hasError) gets set to something truthy (causing a rerendering), isLoading will be false.

    A third solution would be to leave it as is, because Strict Mode does not affect production, so useEffect will not run twice in production, and the loading state will be set correctly.