DEV Community

Cover image for Clean Up Async Requests in `useEffect` Hooks
Yurui Zhang
Yurui Zhang

Posted on • Edited on

Clean Up Async Requests in `useEffect` Hooks

In my previous post, we talked about how to replace some component lifecycle functions with useEffect and useReducer hooks, while making the resource fetching logic re-usable in the app.

https://dev.to/pallymore/refactoring-an-old-react-app-creating-a-custom-hook-to-make-fetch-related-logic-reusable-2cd9

The custom hook we got at the end looks like this:



export const useGet = ({ url }) => {
  const [state, dispatch] = useReducer(reducer, {
    isLoading: true,
    data: null,
    error: null,
  });

  useEffect(() => {
    const fetchData = async () => {
      dispatch(requestStarted());

      try {
        const response = await fetch(url);

        if (!response.ok) {
          throw new Error(
            `${response.status} ${response.statusText}`
          );
        }

        const data = await response.json();

        dispatch(requestSuccessful({ data }));
      } catch (e) {
        dispatch(requestFailed({ error: e.message }));
      }
    };

    fetchData();
  }, [url]);

  return state;
};


Enter fullscreen mode Exit fullscreen mode

Looks pretty neat, right? However it has a critical flaw - if the fetch request is slow, and the component has already unmounted when the async request finishes, you will see this error message from React:

React Error

Or - it could have a serious problem - imagine your component that uses this hook received a different ID before the request finishes - so it tries to fetch data from the new url, and the second request finished just a few ms before the first one - what's gonna happen? Your component will be showing the data from the first request!

The great async/await might make your code look like it is synchronous, but in reality they are just syntax sugar - your code after await will still be executed even your component no longer exists on the page. We should always be careful whenever we want to update the state in an asynchronous function.

How do we prevent this from happening? First of all, we should always try to clean up our effects.

The Clean Up Function

If you don't already know - you can return a function at the end of your useEffect hook. That function will be called whenever that effect is fired again (e.g. when the values of its dependencies have changed), as well as right before the component unmounts. So if you have a useEffect hook that looks like this:



useEffect(() => {
  // logic here

  return () => {
    // clean up
  };
}, []); // no dependencies!


Enter fullscreen mode Exit fullscreen mode

It is actually doing the exact same thing as this code:



class SomeComponent extends React.Component {
  componentDidMount() {
    // logic here
  }

  componentWillUnmount() {
    // clean up
  }
}


Enter fullscreen mode Exit fullscreen mode

If you are attaching an event listener to window, document, or some other DOM elements, you can use removeEventListener in the clean up function to remove them. Similarly, you can clean up setTimeout/setInterval with clearTimeout/clearInterval.

A Simple Solution

Knowing this, you might think: oh well, that's great, we can set a flag that is set to false when the component unmounts so we can skip all the state updates.

And you are right, that's indeed a very simple solution to this problem:



  useEffect(() => {
    let isCancelled = false;
    const fetchData = async () => {
      dispatch(requestStarted());

      try {
        // fetch logic omitted...
        const data = await response.json();

        if (!isCancelled) {
          dispatch(requestSuccessful({ data }));
        }
      } catch (e) {
        if (!isCancelled) {
          dispatch(requestFailed({ error: e.message }));
        }
      }
    };

    fetchData();

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


Enter fullscreen mode Exit fullscreen mode

In this code - whenever a new effect runs (or the component unmounts), the previous' effect's isCancelled is set to true - and we only update the state when it is false. This makes sure that your requestSuccessful and requestFailed actions are only dispatched on the latest request.

Mission accomplished!...?

But You Really Should Do This

There is a better way though. The code above is fine, however, if your fetch request is really slow, even if you don't need the results anymore, it is still going on in the background, waiting for a response. Your user might be clicking around and leaving a bunch of stale requests behind - did you know? There is a limit of how many concurrent requests you can have going on at the same time - usually 6 to 8 depending on which browser your users are using. (This applies to HTTP 1.1 only though, things are changing thanks to HTTP/2 and multiplexing, but that's a different topic.) Your stale requests will be blocking newer requests to be executed by the browser, making your app even slower.

Thankfully, there is a new feature in the DOM API called AbortController which allows you to cancel fetch requests! It is well supported by most browsers (No IE11 though) and we should definitely take advantage of it.

The AbortController is very easy to work with. You can create a new one like this:



const myAbortController = new AbortController();


Enter fullscreen mode Exit fullscreen mode

and you will find two fields on the instance: myAbortController.signal and myAbortController.abort(). signal is to be provided to the fetch call you want to cancel, and when abort is called that fetch request will be cancelled.



fetch(url, { signal: myAbortController.signal });

// call the line below to cancel the fetch request above.
myAbortController.abort(); 


Enter fullscreen mode Exit fullscreen mode

If the request has already completed, abort() won't do anything.

Awesome, now we can apply this to our hook:



  useEffect(() => {
    const abortController = new AbortController();

    const fetchData = async () => {
      dispatch(requestStarted());

      try {
        fetch(url, { signal: abortController.signal });

        // code omitted for brevity

        dispatch(requestSuccessful({ data }));
      } catch (e) {
        dispatch(requestFailed({ error: e.message }));
      }
    };

    fetchData();

    return () => {
      abortController.abort();
    };
  }, [url]);


Enter fullscreen mode Exit fullscreen mode

Now our fetch request will be promptly cancelled for each new effect, or right before the component unmounts.

Handling Cancelled Requests

Just one little thing though - when a request is cancelled it actually throws an error, so our catch block will be executed. We probably don't want to dispatch a requestFailed action in this case. Fortunately we can tell if a request has been aborted by checking the signal on the AbortController instance.

Let's do that in our catch block:



try {
// ...
} catch (e) {
// only call dispatch when we know the fetch was not aborted
if (!abortController.signal.aborted) {
dispatch(requestFailed({ error: e.message }));
}
}

Enter fullscreen mode Exit fullscreen mode




Wrapping It Up

Now our hook can properly cleans up after itself! If your hook does something async, in most cases they should be cleaned up properly to avoid any unwanted side-effects.

If you are using fetch, then abort your requests in the clean up function. Some third party libraries also provide a way to cancel requests (like the CancelToken from axios).

If you want to support older browsers, or your effect doesn't use fetch, but is using some other async operations (like Promise), before cancelable Promises becomes a reality, use the isCancelled flag method instead.

Resources

https://developer.mozilla.org/en-US/docs/Web/API/AbortController

https://reactjs.org/docs/hooks-effect.html

Top comments (55)

Collapse
 
maltoze profile image
maltoze

dispatch also need to be abortable...

Collapse
 
pallymore profile image
Yurui Zhang • Edited

dispatch can be aborted if you use redux-thunk or redux-saga - only applies to async actions though.

for example:

const thunkAction = (payload, signal) => {
  return (dispatch) => {
     fetch(payload, { signal }).then(response => {
        dispatch(respondeLoadedAction(response));
     }).catch(error => {
        if (signal.aborted) {
           // aborted
        } else {
           // errored 
           dispatch(errorAction(error));
        }
     });
  };
}
Enter fullscreen mode Exit fullscreen mode

to use it:

const abortController = new AbortController();

const doStuff = (payload) => dispatch(thinkAction(payload, abortController.signal));

doStuff(payload);
// to abort it: call abortController.abort();
Enter fullscreen mode Exit fullscreen mode

it's not limited to fetch or axios - you can use AbortSignal for many things, just be careful it might not throw error automatically when used outside of making requests.

developer.mozilla.org/en-US/docs/W...

Collapse
 
m27 profile image
m27 • Edited

You are using fetch, is it working the same for axios or is there some change?

Thread Thread
 
pallymore profile image
Yurui Zhang

pretty much the same.

axios can either use an AbortController or a CancelToken, check their examples here: axios-http.com/docs/cancellation

Thread Thread
 
m27 profile image
m27 • Edited

Yes I checked their doc, I have trouble cancelling one request via redux. Basically I have my axios in a service file, then I call the axios req in the action. In my component I have a useEffect which runs when require, and save data to api, and whenever I trigger the button, I would like the call to be cancelled. Any help on how to achieve this ? thanks

Here is my service file :



export const sync = (l, obj, pId, controller) => {
  let a = { ...obj };

  return axios.post(`/${l}/f/ff/Create`, JSON.stringify(cv(a, l, pId)), {
    signal: controller.signal
  });
};
Enter fullscreen mode Exit fullscreen mode

my action file

export const save =
    (l, el, pId,controller ) => async (dispatch) => {
        try {
            dispatch({ type: SAVE_REQUEST });

            await sync(l, el, pId, controller);

            dispatch({
                type: SAVE_SUCCESS,
                payload: el,
            });
        } catch ( error) {
                dispatch({ type: SAVE_FAIL, payload: error});
            }
        }
  };

Enter fullscreen mode Exit fullscreen mode

and my component


 const { current: controller } = useRef(new AbortController());

  useEffect(() => {
    dispatch(save(l,el,p.id,controller)
    },[])

const handleAbort=(){
  controller.abort()
}

  <div>
  <button onclick={handleAbort()}>abort</button>
  </div>
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
pallymore profile image
Yurui Zhang

looks like you are calling abort already in the dispatch line instead of providing a signal. same to the onClick handler. I would do this:

  const { current: controller } = useRef(new AbortController());

  useEffect(() => {
    dispatch(save(l,el,p.id, controller.signal)
    },[])

const handleAbort= () => {
  controller.abort()
}

  <div>
  <button onclick={handleAbort}>abort</button>
  </div>
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
m27 profile image
m27 • Edited

it does not seem to work, idk why, is there a chance you can take a look at the service and action file that I posted, just to check if I have made any mistake thanks

Thread Thread
 
pallymore profile image
Yurui Zhang

could you be a bit more specific though šŸ˜… a minimal example would be helpful.

the method explained in the post definitely works - you probably have something else going on in the app

Thread Thread
 
m27 profile image
m27

yes the component seems fine, it is just that in my action and service file, I am not sure I set up the controller properly

Thread Thread
 
m27 profile image
m27

I managed to make it work, thank you. I was wondering, how do I clear the abort controller once request cancelled ? thanks

Thread Thread
 
pallymore profile image
Yurui Zhang

awesome news. šŸ˜…

oh yea about that there are different methods. I think the abort controller should only be created before each request. one way of doing that is useRef

const abortCtrlRef = useRef(null);

const handleCancel = useCallback(() => {
  abortCtrlRef.current?.abort();
  abortCtrlRef.current = null;
}, []);

useEffect(() => {
  abortCtrlRef.current = new AbortController();

  makeRequest(abortCtrlRef.current.signal);

  return () => {
    handleCancel();
  };
}, [handleCancel, andOtherDeps]);

Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
m27 profile image
m27 • Edited

ok but is there another method, because this one does not work for my use case thanks

Thread Thread
 
m27 profile image
m27 • Edited

This is what I have so far. I have a modal, this modal is saving data to a database, once I click on the abort, i cancelled the call (to save data) in the useEffect. Let 's say now the user exit the modal and decide some other time to go back on the modal to save its data again, the cancel signal is still on the route. How do I clear up the abort controller ?

Here is my service file :



export const sync = (l, obj, pId, controller) => {
  let a = { ...obj };

  return axios.post(`/${l}/f/ff/Create`, {
    signal: controller.signal
  });
};
Enter fullscreen mode Exit fullscreen mode

my action file

export const save =
    (l, el, pId,controller ) => async (dispatch) => {
        try {
            dispatch({ type: SAVE_REQUEST });

            await sync(l, el, pId, controller);

            dispatch({
                type: SAVE_SUCCESS,
                payload: el,
            });
        } catch ( error) {
                dispatch({ type: SAVE_FAIL, payload: error});
            }
        }
  };

Enter fullscreen mode Exit fullscreen mode

and my component


 const { current: controller } = useRef(new AbortController());

  useEffect(() => {
    dispatch(save(l,el,p.id,controller)
    },[])

const handleAbort=(){
  controller.abort()
}

  <div>
<Modal>
<progressbar/>
  <button onclick={handleAbort()}>abort</button>
<button >exit</button>
</Modal>
  </div>
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
pallymore profile image
Yurui Zhang

in your example it's hard to tell what the issue is or what are you trying to do.

two things that's kinda obvious here:

  1. you are not creating a new AbortController before each request (check my previous example)
  2. your useEffect only fires once (when the component mounts) - the request is not made on demand, which seems to be what you are trying to do here. If you want the request to be made every time the modal is opened, you will need to make sure opening the modal (either rendering the modal, or changing the state) is tied to the effect/request.

also, when you say "it doesn't work" - please elaborate why or how. there could be many reasons for broken code, without errors/logs/descriptions, it's impossible to tell what might be wrong by simply looking at a few lines of code.

Collapse
 
prberg profile image
Paul Berg

Great article! However I don't see why you couldn't use the AbortController even if you're not using fetch. You'd just check for the abortController.signal.aborted flag in whatever resolve or reject block you get from the Promise.

Collapse
 
pallymore profile image
Yurui Zhang

Thanks - yes that's possible - however in that case the AbortController instance basically acts like a boolean flag. šŸ˜…

it can be helpful in certain scenarios though. if you are working with a lot of Promises - I'd recommend checking out npmjs.com/package/cancelable-promise

Collapse
 
prberg profile image
Paul Berg

Whoah, that library looks like just what I was looking for. Thanks!

Collapse
 
smritispotnana profile image
smriti-spotnana

I am dispatching a thunk call in my useEffect. Now, if I use isCancelled flag as suggested, I want to understand how is it actually preventing state update when my component is unmounted. now, thunk is calling the api in the background, and sets the redux state in the background. now, when happens? When is if(!isCancelled) inside the useEffect is being checked?

Collapse
 
pallymore profile image
Yurui Zhang • Edited

In your case using the flag is not going to work.
If you are using fetch to make requests you can use AbortController (just provide the signal to the thunk action)

useEffect(() => {
   const abortController = new AbortController();

  dispatch(thunkAction({ abortSignal: abortController.signal })); // provide abortSignal to `fetch` in your middeware;

  return () => {
    abortController.abort();
  };
}, [...]);

in your request handler's catch block, make sure checking if the error is an "AbortError"

  fetch(..., { signal: abortSignal })
   .then(/* set state with redux */)
   .catch((e) => {
      if (e.name !== 'AbortError') {
         // request failed
      } else {
         // request was cancelled.
      }
   });

There are many different implementations for making requests with thunk actions - could you show some code?

Collapse
 
smritispotnana profile image
smriti-spotnana • Edited

my component file has this -

useEffect((): void => {
    const params = qs.unstringify(search);
    dispatch(thunk(params));
  }, [dispatch, search]);

in a diff .js file, I have written the thunk function. which uses axios internally -

import api from "./api";
export const results = params => async (dispatch) => {
  try {
   const { data } = await api("POST", "search", { data });
   dispatch(updateReduxState(data);
   }
 catch {
   dispatch(failure(false));
  };
};
Thread Thread
 
pallymore profile image
Yurui Zhang

Looks like you can just introduce a second parameter to your thunk function:

// thunk function
export const thunk = async (params, cancelToken) => {
  try {
    const { data } = await api('POST', 'search', { data, cancelToken });
  }
  catch(e) {
    if (!axios.isCancel(error)) {
       // not cancelled  dispatch failure action
    } else {
       // canceled - handle it or ignore it
    }
  }
}

now in your component you should provide the cancel token to thunk

useEffect(() => {
  const source = axios.CancelToken.source();
  dispatch(thunk(params, source.token));
  return () => {
    source.cancel(); // cancel previous request when effect fires again, or when component unmounts
  };
});
Thread Thread
 
smritispotnana profile image
smriti-spotnana

so, inside my api function, I am creating new token on every request - if it's the same request though, then it cancels the prev token/req, and re-generate the token and handling everything related to axios inside this function.
So, I will need to change it and basically create token inside useEffect.
Will you be able to redirect me to React docs/github/etc where they suggest this solution? Thank you

Thread Thread
 
pallymore profile image
Yurui Zhang

No, the token should be created in your useEffect call. A new token is created for every new "effect". cancel / abort is called whenever the effect re-fires (e.g. when the parameters changed, or when the component unmounts), the cleanup function is called, cancelling the previous request - in your API function you should check if a request has been aborted in your catch block and handle it accordingly.

some helpful articles:
reactjs.org/blog/2015/12/16/ismoun...
reactjs.org/docs/hooks-effect.html
github.com/axios/axios#cancellation

Collapse
 
mav1283 profile image
Paolo • Edited

But what about other api request especially the event driven ones such as post, patch, put, and delete? I followed this tutorial using axios instead of fetch but i still get the same warning/error. Axios's CancelToken is the same but I can't seem to make a post, patch, put and delete request without re-starting the browser, the initial list component that was fetch in component did mount was unmounted during "isLoading" phase, need help with my code here:

const postRequest = useCallback(() => {
let source = axios.CancelToken.source();
const postData = async (entry) => {
dispatch(loading());
try {
const response = await axios.post(
'/list',
{
cancelToken: source.token,
},
entry
);
dispatch(processingRequest(response.data));
} catch (err) {
if (axios.isCancel(err)) {
dispatch(handlingError);
}
}
};

postData();
return () => {
  source.cancel();
};
Enter fullscreen mode Exit fullscreen mode

}, []);

Collapse
 
pallymore profile image
Yurui Zhang • Edited

Hi - I looked at your program, it doesn't work because the request is never cleaned up. My post talks about automatically clean up requests with useEffect indeed it might not be so easy to work with for POST/PUT, etc, or requests that only fire on user action (not via an effect).

Your code, uses useCallback which is just a simple memoizer. the return at the end won't clean it up for you. We can rewrite it to a function creator that returns a function that automatically cleans up its previous request when called again:

const makeRequester = () => {
  let cancelToken; 

  return async (entry) => {
    if (cancelToken) {
      cancelToken.cancel();
    }

    cacelToken = axios.CancelToken.source();

    dispatch(loading());

    try {
      const response = await axios.post('/list', entry, { cancelToken: source.token });
      dispatch(processingRequest(response.data));
    } catch (e) {
      if (!axios.isCancel(e)) {
        dispatch(requestError(e.message));
      } else {
        dispatch(requestCanceled());
      }
    }
  }
};

to use it:

const postList = makeRequester();  // we have to create the function first.

// later...
const handleFormSubmit = () => {
  // ... gather newEntry data here
  postList(newEntry);
}

this function will only allow one request being made at the same time - it doesn't cancel the request for you when the component unmounts though, to do that, we should move cancelToken to a ref, here's a possible implementation for that:

// in the component
const cancelToken = React.useRef(null);

// this effect cancels the requests when the component unmounts;
React.useEffect(() => {
  return () => {
    if (cancelToken.current) {
       cancelToken.current.cancel();
    }
  };
}, []);

const postList = async (entry) => {
    // cancels the previous request
    if (cancelToken.current) {
      cancelToken.current.cancel();
    }

    // creates a new token 
    cacelToken.current = axios.CancelToken.source();

    dispatch(loading());
    try {
      // ... same as the example above
    } catch (e) {
      // ... same 
    }
}

now we don't need to "make" a new requester anymore, to use it we can call it directly with the new "entry". This postList function automatically cancels its previous request when called again, and if there are any pending requests, they will be canceled when the component unmounts.

Collapse
 
mav1283 profile image
Paolo • Edited

I followed the new one but i couldn't make any request for the first approach, It's a simple mern stack, I'm using context api + useReducer:

Actions:

const loading = () => {
  return {
    type: LOADING,
  };
};

const processingRequest = (params) => {
  return {
    type: PROCESSING_REQUEST,
    response: params,
  };
};

const handlingError = () => {
  return {
    type: HANDLING_ERROR,
  };
};

Reducer:

export const initialState = {
  isError: false,
  isLoading: false,
  data: [],                   
};

const listReducer = (state, { type, response }) => {
  switch (type) {
    case LOADING:
      return {
        ...state,
        isLoading: true,
        isError: false,
      };
    case PROCESSING_REQUEST:
      return {
        ...state,
        isLoading: false,
        isError: false,
        data: response,
      };
    case HANDLING_ERROR:
      return {
        ...state,
        isLoading: false,
        isError: true,
      };
    default:
      return state;
  }
};

here's my custom hook for all 5 types of api request:

 const useApiReq = () => {
  const [state, dispatch] = useReducer(listReducer, initialState);

  const getRequest = () => {
    let source;
    return async () => {
      if (source) {
        source.cancel();
      }

      source = axios.CancelToken.source();

      dispatch(loading());
      try {
        const response = await axios.get('/list', {
          cancelToken: source.token,
        });
        dispatch(processingRequest(response.data));
      } catch (err) {
        if (axios.isCancel(err)) {
          dispatch(handlingError);
        }
      }
    };
  };

  const postRequest = (entry) => {
    let source;
    return async (entry) => {
      if (source) {
        source.cancel();
      }

      source = axios.CancelToken.source();

      dispatch(loading());
      try {
        const response = await axios.post(
          '/list',
          {
            cancelToken: source.token,
          },
          entry
        );
        dispatch(processingRequest(response.data));
      } catch (err) {
        if (axios.isCancel(err)) {
          dispatch(handlingError);
        }
      }
    };
  };

  const patchRequest = (id, updated_entry) => {
    let source;
    return async (id, updated_entry) => {
      if (source) {
        source.cancel();
      }

      source = axios.CancelToken.source();

      dispatch(loading());
      try {
        const response = await axios.patch(
          `/list/${id}`,
          {
            cancelToken: source.token,
          },
          updated_entry
        );
        dispatch(processingRequest(response.data));
      } catch (err) {
        if (axios.isCancel(err)) {
          dispatch(handlingError);
        }
      }
    };
  };

  const putRequest = (id, updated_entry) => {
    let source;
    return async (id, updated_entry) => {
      if (source) {
        source.cancel();
      }

      source = axios.CancelToken.source();

      dispatch(loading());
      try {
        const response = await axios.put(
          `/list/${id}`,
          {
            cancelToken: source.token,
          },
          updated_entry
        );
        dispatch(processingRequest(response.data));
      } catch (err) {
        if (axios.isCancel(err)) {
          dispatch(handlingError);
        }
      }
    };
  };

  const deleteRequest = (id) => {
    let source;
    return async (id) => {
      if (source) {
        source.cancel();
      }

      source = axios.CancelToken.source();
      dispatch(loading());
      try {
        const response = await axios.delete(`/list/${id}`, {
          cancelToken: source.token,
        });
        dispatch(processingRequest(response.data));
      } catch (err) {
        if (axios.isCancel(err)) {
          dispatch(handlingError);
        }
      }
    };
  };

  return [
    state,
    getRequest,
    postRequest,
    patchRequest,
    putRequest,
    deleteRequest,
  ];
};

I'm using the custom hooks as values to the context api, here's the main component where the list component is unmounted during the "isLoading" phase, as you can see the get request is inside the useEffect:

function Main() {
  const { state, getRequest } = useContext(AppContext);
  const { isError, isLoading, data } = state;

  useEffect(() => {
    getRequest();
  }, [getRequest]);

  return (
    <main className='App-body'>
      <Sidebar />
      <div className='list-area'>
        {isLoading && (
          <p className='empty-notif'>Loading data from the database</p>
        )}
        {isError && <p className='empty-notif'>Something went wrong</p>}
        {data.length == 0 && <p className='empty-notif'>Database is empty</p>}
        <ul className='parent-list'>
          {data.map((list) => (
            <ParentListItem key={list._id} {...list} />
          ))}
        </ul>
      </div>
    </main>
  );
}

Here's one of the modals for event driven requests like post:

const AddList = ({ exitHandler }) => {
  const { postRequest } = useContext(AppContext);
  const [newList, setNewList] = useState({});
  const inputRef = useRef(null);

  /* On load set focus on the input */
  useEffect(() => {
    inputRef.current.focus();
  }, []);

  const handleAddList = (e) => {
    e.preventDefault();
    const new_list = {
      list_name: inputRef.current.value,
      list_items: [],
    };
    setNewList(new_list);
  };

  const handleSubmit = (e) => {
    e.preventDefault();

    // post request here
    postRequest(newList);
    exitHandler();
  };

  return (
    <form onSubmit={handleSubmit} className='generic-form'>
      <input
        type='text'
        ref={inputRef}
        placeholder='List Name'
        onChange={handleAddList}
      />
      <input type='submit' value='ADD' className='btn-rec' />
    </form>
  );
};
Thread Thread
 
pallymore profile image
Yurui Zhang • Edited

Hi - that's a lot of code - i took a quick look, one thing I probably didn't explain well with my first example is that the makeRequest method returns a function that makes requests when called. (i know it's a bit confusing)

in your example, your getRequest or postRequest methods are factories - to use them, you have to do something like:

// in the component
const { state, getRequest } = useContext(AppContext);
const getList = React.useRef(getRequest()); // note that `getRequest` is called right away

  useEffect(() => {
    getList.current();  // calls on mount
  }, []);

please try to follow my 2nd example as it cleans up the requests when component unmounts. I'd recommend trying to start small, don't try to get everything working in one go, instead, try to focus on only 1 method, and get it to work correctly (and tested) first.

The new postRequest could look something like this:

  const postRequest = async (entry, cancelToken) => {
      dispatch(loading());
      try {
        const response = await axios.post(
          '/list', entry,
          {
            cancelToken,
          }
        );
        dispatch(processingRequest(response.data));
      } catch (err) {
        if (!axios.isCancel(err)) {
          dispatch(handlingError);
        }
      }
    };
  };

Please note this method is very different from the first one - to use it, do something like this in the component:

  const { postRequest } = useContext(AppContext);
const cancelToken = React.useRef(null);

// this effect cancels the requests when the component unmounts;
React.useEffect(() => {
  return () => {
    if (cancelToken.current) {
       cancelToken.current.cancel();
    }
  };
}, []);

const createNewList = (entry) => {
    // cancels the previous request
    if (cancelToken.current) {
      cancelToken.current.cancel();
    }

    // creates a new token 
    cacelToken.current = axios.CancelToken.source();

    postRequest(entry, cancelToken.current.token);
}

  const handleSubmit = (e) => {
    e.preventDefault();
    createNewList(newList);
    exitHandler();
  };

there were a couple of other issues with your code, for exmaple, axios.post takes configuration as the 3rd parameter, not the 2nd; and in your catch blocks axios.isCancel means the request was canceled (instead of encountered an error) - usually we want to handle error when the request was NOT canceled.

Anyways, try to get a single request working properly first before trying to optimize or generalize your use case, don't worry about separating functionality or abstraction at this stage.

Thread Thread
 
mav1283 profile image
Paolo

Hi sorry for the late reply, I followed your suggestions and here's what my app can do:

  1. GET request on initial load
  2. PATCH and PUT request but have to refresh the page to see the changes,
  3. I cannot POST, DELETE

Here's my updated custom hook that has all the factory requests:

const useApiReq = () => {
  const [state, dispatch] = useReducer(listReducer, initialState);

  const getRequest = async (cancelToken) => {
    dispatch(loading());
    try {
      const response = await axios.get('/list', {
        cancelToken,
      });
      dispatch(processingRequest(response.data));
    } catch (err) {
      if (!axios.isCancel(err)) {
        dispatch(handlingError);
      }
    }
  };

  const postRequest = async (entry, cancelToken) => {
    dispatch(loading());
    try {
      const response = await axios.post('/list', entry, {
        cancelToken,
      });
      dispatch(processingRequest(response.data));
    } catch (err) {
      if (!axios.isCancel(err)) {
        dispatch(handlingError);
      }
    }
  };

  const patchRequest = async (id, updated_entry, cancelToken) => {
    dispatch(loading());
    try {
      const response = await axios.patch(`/list/${id}`, updated_entry, {
        cancelToken,
      });
      dispatch(processingRequest(response.data));
    } catch (err) {
      if (!axios.isCancel(err)) {
        dispatch(handlingError);
      }
    }
  };

  const putRequest = async (id, updated_entry, cancelToken) => {
    dispatch(loading());
    try {
      const response = await axios.put(`/list/${id}`, updated_entry, {
        cancelToken,
      });
      dispatch(processingRequest(response.data));
    } catch (err) {
      if (!axios.isCancel(err)) {
        dispatch(handlingError);
      }
    }
  };

  const deleteRequest = async (id, cancelToken) => {
    dispatch(loading());
    try {
      const response = await axios.delete(`/list/${id}`, {
        cancelToken,
      });
      dispatch(processingRequest(response.data));
    } catch (err) {
      if (!axios.isCancel(err)) {
        dispatch(handlingError);
      }
    }
  };

  return [
    state,
    getRequest,
    postRequest,
    patchRequest,
    putRequest,
    deleteRequest,
  ];
};

export default useApiReq;

I tried negating the: axios.isCancel(err) but to no avail, here's my api request codes.

GET Request:

function Main() {
  const { state, getRequest } = useContext(AppContext);
  const cancelToken = useRef(null);
  const { isError, isLoading, data } = state;

  const getData = () => {
    if (cancelToken.current) {
      cancelToken.current.cancel();
    }

    cancelToken.current = axios.CancelToken.source();
    getRequest(cancelToken.current.token);
  };

  useEffect(() => {
    getData();
  }, []);

  useEffect(() => {
    /* axios cleanup */
    return () => {
      if (cancelToken.current) {
        cancelToken.current.cancel();
      }
    };
  }, []);

  return (
    <main className='App-body'>
      <Sidebar />
      <div className='list-area'>
        {isLoading && (
          <p className='empty-notif'>Loading data from the database</p>
        )}
        {isError && <p className='empty-notif'>Something went wrong</p>}
        {data.length == 0 && <p className='empty-notif'>Database is empty</p>}
        <ul className='parent-list'>
          {data.map((list) => (
            <ParentListItem key={list._id} {...list} />
          ))}
        </ul>
      </div>
    </main>
  );
}

export default Main;

POST Request:

const AddList = ({ exitHandler }) => {
  const { postRequest } = useContext(AppContext);
  const [newList, setNewList] = useState({});
  const cancelToken = useRef(null);
  const inputRef = useRef(null);

  /* On load set focus on the input */
  useEffect(() => {
    inputRef.current.focus();
  }, []);

  useEffect(() => {
    /* clean up axios */
    return () => {
      if (cancelToken.current) {
        cancelToken.current.cancel();
      }
    };
  }, []);

  const handleAddList = (e) => {
    e.preventDefault();
    const new_list = {
      list_name: inputRef.current.value,
      list_items: [],
    };
    setNewList(new_list);
  };

  const createNewList = (entry) => {
    if (cancelToken.current) {
      cancelToken.current.cancel();
    }

    /* create token source */
    cancelToken.current = axios.CancelToken.source();
    postRequest(entry, cancelToken.current.token);
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    createNewList(newList);
    exitHandler();
  };

  return (
    <form onSubmit={handleSubmit} className='generic-form'>
      <input
        type='text'
        ref={inputRef}
        placeholder='List Name'
        onChange={handleAddList}
      />
      <input type='submit' value='ADD' className='btn-rec' />
    </form>
  );
};

export default AddList;

DELETE Request:

const DeleteList = ({ exitHandler }) => {
  const { state, deleteRequest } = useContext(AppContext);
  const { data } = state;
  const cancelToken = useRef(null);
  const selectRef = useRef();
  const [targetListId, setTargetListId] = useState();

  useEffect(() => {
    selectRef.current.focus();
  }, []);

  useEffect(() => {
    /* cleanup axios */
    return () => {
      if (cancelToken.current) {
        cancelToken.current.cancel();
      }
    };
  }, []);

  useEffect(() => {
    setTargetListId(data[0]._id);
  }, [data]);

  const deleteList = (entry) => {
    if (cancelToken.current) {
      cancelToken.current.cancel();
    }

    /* create token source */
    cancelToken.current = axios.CancelToken.source();
    deleteRequest(entry, cancelToken.current.token);
  };

  const handleDeleteList = (e) => {
    e.preventDefault();
    deleteList(targetListId);
    exitHandler();
  };

  const handleChangeList = (e) => {
    setTargetListId(e.target.value);
    console.log(targetListId);
  };

  return (
    <form onSubmit={handleDeleteList} className='generic-form'>
      <label>
        <select
          ref={selectRef}
          value={targetListId}
          onChange={handleChangeList}
          className='custom-select'
        >
          {data.map((list) => (
            <option key={list._id} value={list._id}>
              {list.list_name}
            </option>
          ))}
        </select>
      </label>
      <input type='submit' value='DELETE' className='btn-rec' />
    </form>
  );
};

export default DeleteList;
Thread Thread
 
pallymore profile image
Yurui Zhang

hi @paolo - sorry I just saw this. Could you setup a github repo or add me to your existing one? my github handle is @pallymore

alternatively could you set this up on codesandbox.io ? it'll be easier to read/write code there, thanks!

Thread Thread
 
mav1283 profile image
Paolo

Thanks so much, I added you on github :)

Collapse
 
farjallahmed profile image
Farjallahmed • Edited

i have implement axios way, but the problem that it cancel all requests immediately and not after leaving the component.
is that a syntax error ?

import { useEffect, useState } from "react";
import Api from "#shared/Api";
import { CancelToken, isCancel } from "axios";
const GetReqhandler = (path) => {

const [data, setData] = useState(null);

useEffect(() => {
    const source = CancelToken.source();

    Api.ApiInstance.get(path, { cancelToken: source.token })
        .then(res => { setData(res.data) })
        .catch((e) => {
            if (isCancel(e)) return;
            throw e;
        })
    return () =>  source.cancel(); 
}, [])

return { data };

}
export default GetReqhandler;

Collapse
 
pallymore profile image
Yurui Zhang

hmm this looks correct to me - could you setup an code example on codesandbox?

Also this is a custom hook, right?

Collapse
 
farjallahmed profile image
Farjallahmed

Yeah, this is a custom hook, here is an example of it's implementation,
i am reformatting code into an old react project, so i tried to implement it to see if it's efficient for the performance, so i can change it in the whole the project (Notice : when i implement it, i didn't change the other normal requests. It might be because of that, i don't know)

useEffect(() => {
    document.title = "Residanat | Liste des abonnƩes";
    GetAbonnes(page, sortOrder);
}, [page]);
const { data: abonne } = GetReqHandler(`admin/abonner?page=${page}`)
console.log("data ",abonne)

const FilterList = (e) => {
    setSortOrder(e.target.value);
    GetAbonnes(1, e.target.value);
    page += 1;
}

const ProfileUtilisateur = (user) => props.history.push(`/home/abonne/${user._id}`, { user });

const GetAbonnes = (pageNumber, sortBy) => {
    AbonneService.FilterListAbonne(pageNumber, sortBy)
        .then((res) => {
            if (res.error) ErrorHandler(res.error)
            else {
                if (pageNumber > 1) setListAbonne([...listAbonne, ...res.response]);
                else setListAbonne([...res.response]);
                if (res.response.length < 15) setLoadMore(false);
                else {
                    page += 1;
                    setLoadMore(true)
                }
            }
        })
}
Thread Thread
 
pallymore profile image
Yurui Zhang

Sorry for my late response.

I don't see any obvious errors with the implementation. maybe I'm not getting your question right - are you saying cancel is called right away when the component is still mounted? that shouldn't happen since you provided [] to useEffect which means it'll only run once and the clean up function is only called when the component unmounts (similar to componentDidMount + componentWillUnmount).

I made something similar to your first example and it works.
codesandbox.io/s/fast-cdn-j37lb?fi...

one quick things though: hook names should start with use - instead of GetReqHandler you probably want to rename it do useGetReqHandler.

Another thing to note is if you have hot module reloading - your page might unmount and re-mount the component which will cause cancellations - but if you are not doing that, I don't think the problem is here, you might want to check if your code works properly if you change it to a class component.

Thread Thread
 
farjallahmed profile image
Farjallahmed

Yea i thnik this is the problem.
instead of codesandbox here is the project on gitlab
React Project

the useGetReques used under home/abonne/abonne.js
and it's parent is home/home_container.js

if you could suggest a solution to solve this problem using hooks or i any kind of parameters.

Thread Thread
 
pallymore profile image
Yurui Zhang

Sorry again - would you mind adding me to that project? my gitlab handle is @pallymore

Thanks

Thread Thread
 
farjallahmed profile image
Farjallahmed

I have solve it, i had to change the root component of routes to class instead of fuctional component and it solved the problem

Collapse
 
ffigueroa2803 profile image
Frankie Figueroa MuƱoz • Edited

Could you help me how to cancel request using userMeno with dispatch I get this error ( Warning: 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. )

const [stateApp, dispatch] = useReducer(
(stateApp, action) => {
switch (action.type) {
case "SET_APP":
return {
...stateApp,
data: { ...action.payload },
loading: false,
success:true,
error: undefined
};
case "SET_ERROR":
return {
...stateApp,
error: { ...action.payload },
loading:false,
data: {},
success:false
};
default:
return stateApp
}
},
{
data: {},
error: undefined,
loading: true,
success: false
},
)

const app = useMemo(() => ({
    setting: async () => {
        await authentication.get("app/me").then( response => {
            let { success, message, app } = response.data
            if (!success) throw new Error(message)
            dispatch(createAction("SET_APP", app))
        }).catch(async error => {
            await LOGOUT()
            dispatch(createAction("SET_ERROR", error))
        })
    }
}), [])
Enter fullscreen mode Exit fullscreen mode
Collapse
 
pallymore profile image
Yurui Zhang

Hi - I'd recommend not to use useMemo in this case - it should be reserved for memoizing results from expensive operations - not functions! useMemo should be avoided unless you absolutely need it for performance reasons (in real life this doesn't happen very often).

Back to the topic - in your case your method is not cancelled when the component unmounts - the right solution would be creating a cancel token (or AbortController if you are using fetch ) and provide the signal to your authentication.get method - and then in catch make sure you check for AbortErrors. This depends on the implementation of your authentication.get method.

Alternatively - you can do something like this:

const abortController = useRef(new AbortController());

useEffect(() => () => abortController.current.abort(), []); // aborts when the hook unmounts.

// then later in your then/catch blocks:
// ...
async () => {
  await authentication.get('app/me').then(r => {
    if (abortController.current.signal.aborted) return; // ignore the rest of the code if signal is aborted
    // update state
  }).catch(e => {
    if (abortController.current.signal.aborted) return; // same ignore the rest of the code if signal is aborted
    // update state
  })
}
Enter fullscreen mode Exit fullscreen mode

please note the request is not aborted unless you provide the signal to your fetch call. this code here only prevents react from performing state updates.

Collapse
 
gvozdenkov profile image
Arty Gvozdenkov • Edited

This is a wonderfully flexible solution! Thanks a lot! I understood useReducer much better. But I got a problem. I carefully copied the code and I am getting Maximum update depth exceeded error. This error occurs due to useEffect dependencies on url. If I set [ ] empty array in dependencies all ok, I get my remote data. What am I missing?

useFetch.js

export const useFetch = ({ endpoint, options = {} }) => {
  const [state, dispatch] = useReducer(reducer, {
    isLoading: true,
    data: null,
    error: null,
  });

  useEffect(() => {
    const abortController = new AbortController();
    dispatch(requestStarted());

    const fetchData = async () => {
      try {
        const url = `${serverConfig.baseUrl}/${endpoint}`;
        const res = await fetch(url, {
          headers: serverConfig.headers,
          ...options,
          signal: abortController.signal,
        });

        if (!res.ok) {
          throw new Error(`Request Error ${res.status} ${res.statusText}`);
        }

        const data = await res.json();

        dispatch(requestSuccessful({ data }));
      } catch (e) {
        if (!abortController.signal.aborted) {
          dispatch(requestFailed({ error: e.message }));
        }
      }
    };

    fetchData();

    return () => abortController.abort();
  }, [endpoint, options]);

  return state;
};
Enter fullscreen mode Exit fullscreen mode

And use it in App.js

function App() {
  const { data, error, isLoading } = useFetch({ endpoint: 'ingredients' });

  return (
    <div className="App">
      <header>
        <h1>Context + useRducer + custom Hooks</h1>
      </header>
      <main>
           {isLoading ? <Loading /> : <List items={data} />}
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
pallymore profile image
Yurui Zhang

sorry for the late response - in your code you are declaring dependency on options - which is an object, since react only does shallow comparison, if your options are not memoized, it will trigger useEffect on every render (and cancel previous requests).

to solve this - I'd recommend declaring an explicit list of simple values that you support in options (instead of accepting everything)

also I'd remove the default value options = {} - since react will create a fresh object {} on every render, causing useEffect to fire unnecessarily.

Collapse
 
trickydisco78 profile image
trickydisco78

how does this work with axios?

Collapse
 
pallymore profile image
Yurui Zhang

axios has this thing called CancelToken: github.com/axios/axios#cancellation

it is very similar to AbortController šŸ˜„

however I would not use axios in the front end though. fetch is very easy to work with - if you want some of the axios' default behaviors (throw on 4xx/5xx, returns data by default) you can easily wrap fetch in your own helper function to do that.

Collapse
 
peteramd profile image
Peter Anthony Melecio Duot

Why are you not recommending using axios?

Thread Thread
 
pallymore profile image
Yurui Zhang

Because fetch is already pretty good. I'm not against axios - if you know what you are doing. For any new devs I'd highly recommend learning all the basic DOM APIs and utilities instead of trying to find a third party library for everything.

Thread Thread
 
peteramd profile image
Peter Anthony Melecio Duot

Cool, yeah, best tip, learn all basic DOM API, I'm currently doing this one.
Tbh, this is a very underrated tip but very helpful in the long run.

Collapse
 
iquirino profile image
Igor Quirino

A complete guide here: wareboss.com/react-hook-clean-up-u...

Collapse
 
bouncydragon profile image
Duane Cary

How does this works with redux and redux-saga? is it advisable to clean up on every fetch?

Collapse
 
pallymore profile image
Yurui Zhang

If you are using takeLatest - redux-saga already cancels the effect for you. If you want to abort the request as well, try this:

export function* requestAPI(action) {
  const abortController = new AbortController();
  try {
    const response = yield call(fetch, url, { ...fetchParams, signal: abortController.signal });
  } finally {
    if (yield cancelled()) {
      abortController.abort();
    }
  }
}

If you want to cancel sagas manually, check out their cancellation documentation:
redux-saga.js.org/docs/advanced/Ta...

Collapse
 
retyui profile image
Davyd NRB • Edited

or even better - you could write your custom fetchAPI, which would look something like this

import axios, { CancelToken } from 'axios'
import { CANCEL } from 'redux-saga'

export default function fetchAPI(url) {
  const source = CancelToken.source()
  const request = axios.get(url, { cancelToken: source.token })
  request[CANCEL] = () => source.cancel()
  return request
}
Enter fullscreen mode Exit fullscreen mode

link: github.com/redux-saga/redux-saga/i...

Collapse
 
pyyding profile image
Kaspar PĆ¼Ć¼ding

Cool but how do you test it?

Collapse
 
pyyding profile image
Kaspar PĆ¼Ć¼ding • Edited

Something like this:

  it('should fetch on mount and abort call on unmount', async () => {
    const abortCall = jest.fn()
    global.AbortController = class {

      signal = 'test-signal'

      abort = abortCall

    }

    fetch.mockResolvedValueOnce({ result: { body: { test: 'test-value' } } })

    const { getByText, unmount } = render(<MyComponent />)

    expect(getByText('Loading...')).toBeInTheDocument()

    const expectedOptions = { signal: 'test-signal' }
    expect(fetch).toHaveBeenCalledWith('test-url', expectedOptions)
    expect(fetch).toHaveBeenCalledTimes(1)

    await waitForElement(() => getByText('test-value'))

    expect(abortCall).toHaveBeenCalledTimes(0)

    unmount()

    expect(abortCall).toHaveBeenCalledTimes(1)
  })
Collapse
 
pallymore profile image
Yurui Zhang

I think that might work - however it seems to be testing implementation details - which might be ok if your hook does nothing when aborted.

For testing with fetch I usually use something like sinon's fakeServer. You can intercept requests but not respond to it - unmount the component (or anything that triggers an abort) and check if corresponding side effects are firing (or not firing - e.g. no actions were dispatched)