DEV Community

Discussion on: Clean Up Async Requests in `useEffect` Hooks

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 :)