How to add an abort feature to my custom useFetchOnClick hook?

102 Views Asked by At

I'm working on a webapp with quite a few "fetch on click" events, and they all require consideration (as in, I have to handle the cases when the user is waiting, before the fetch, if it succeeds, if it errors, etc). I noticed that I was writing very similar code all over the place, so I decided to create a special useFetchOnClick hook, that'd fetch when a button is clicked. And so far this is what I have:

Boilerplate reducer code to make the custom hook easier to read:

const FetchType = {
    FETCH_START: 'FETCH_START',
    FETCH_SUCCESS: 'FETCH_SUCCESS',
    FETCH_ERROR: 'FETCH_ERROR'
}

const defaultState = {
    loading: false,
    error: false,
    success: false,
    nthFetch: 0,
    data: null
}

const fetchReducer = (state , action) => {
    switch(action.type){
        case FetchType.FETCH_START:
            return {
                ...state,
                loading: true,
                error: false,
                success: false,
                nthFetch: state.nthFetch + 1
            }
        case FetchType.FETCH_SUCCESS:
            return {
                ...state,
                loading: false,
                error: false,
                success: true,
                data: action.payload
            }
        case FetchType.FETCH_ERROR:
            return {
                ...state,
                loading: false,
                error: true,
                success: false,
                data: null
            }
        default:
            return state
    }
}

The custom hook:

// the responseConversion function is used to convert the response to the desired format
// for example `response => response.json()` or `response => response.blob()`
const useFetchOnClick = (responseConversion) => {
    const [state, dispatch] = useReducer(fetchReducer, defaultState)

    const fetchOnClick = (url, options) => {
        const abortController = new AbortController()
        const signal = abortController.signal

        dispatch({ type: FetchType.FETCH_START })
        fetch(url, { ...options, signal })
            .then(responseConversion)
            .then(data => dispatch({ type: FetchType.FETCH_SUCCESS, payload: data }))
            .catch(error => {
                dispatch({ type: FetchType.FETCH_ERROR })
                console.log(error)
            })

        // this won't abort the fetch
        return () => abortController.abort()
    }

    return [state, fetchOnClick]
} 

Example usage in a component:

export default function TestComponent() {
  const [state, fetchOnClick] = useFetchOnClick(response => response.json())

  return (
    <div>
      <p>test component</p>
      <button
        onClick={() => fetchOnClick("https://api.chucknorris.io/jokes/random")}
      >
        Chuck Norris Fact
      </button>
      {
        state.nthFetch === 0 ? <p>Press the button to get started</p> :
        state.loading ? <p>Loading...</p> :
        state.error ? <p>Error</p> :
        <p>{state.data.value}</p>
      }
    </div>
  )
}

However, I also want to include a "fetch abort" feature, so that if the user has bad internet connection and presses the button twice, they wouldn't trigger 2 fetches that'd basically replace each other. Besides that I heard that's just good practice.

What can I do to add that functionality? Do I need a complete redesign of my hook? Or is there some other method or feature that I'm just not aware of?

1

There are 1 best solutions below

2
Josh Kelley On

If all that's needed is for a single useFetchOnClick to abort its own, previous useFetchOnClick actions, then you can use a React ref as a lightweight way of storing a value. It's probably also worth adding an effect to abort any in-progress fetch operations when the hook is unmounted.

const useFetchOnClick = (responseConversion) => {
    const [state, dispatch] = useReducer(fetchReducer, defaultState)
    const abortRef = useRef();

    const fetchOnClick = (url, options) => {
        if (abortRef.current) {
            abortRef.current.abort()
        }

        abortRef.current = new AbortController()
        const signal = abortRef.current.signal

        dispatch({ type: FetchType.FETCH_START })
        fetch(url, { ...options, signal })
            .then(responseConversion)
            .then(data => dispatch({ type: FetchType.FETCH_SUCCESS, payload: data }))
            .catch(error => {
                if (error.name !== 'AbortError') {
                    dispatch({ type: FetchType.FETCH_ERROR })
                    console.log(error)
                }
            })
    }

    useEffect(() => {
        return () => {
            if (abortRef.current) {
                abortRef.current.abort()
            }
        }
    }, [])

    return [state, fetchOnClick]
}

This handles request deduplication within one component and stopping in-progress requests when they're no longer needed, but you may later find that you want other enhancements (such as retrying on error, deduplicating requests for the same fetch URL across multiple components, etc.). It's worth taking a look at libraries such as RTK Query, TanStack Query, or SWR, which can take care of all of this for you (and remove the need to manage fetch-related state yourself).