loading...
Cover image for Clean Up Async Requests in `useEffect` Hooks

Clean Up Async Requests in `useEffect` Hooks

pallymore profile image Yurui Zhang Updated on ・4 min read

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;
};

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!

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

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

  componentWillUnmount() {
    // clean up
  }
}

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]);

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();

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(); 

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]);

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 }));
  }
}

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

Discussion

pic
Editor guide
Collapse
mav1283 profile image
Paolo

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();
};

}, []);

Collapse
pallymore profile image
Yurui Zhang Author

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

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 Author

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 Author

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
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 Author

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

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 Author

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 Author

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
farjallahmed profile image
Farjallahmed

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 Author

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 Author

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 Author

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
trickydisco78 profile image
trickydisco78

how does this work with axios?

Collapse
pallymore profile image
Yurui Zhang Author

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 Author

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
Collapse
pyyding profile image
Kaspar Püüding

Cool but how do you test it?

Collapse
pyyding profile image
Kaspar Püüding

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 Author

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)

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 Author

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...