5

I am creating a custom hook to fetch api on form submit, I make the api call inside an useEffect hook and I have a reducer to handle the hook's states. One of the states is trigger set to false at first which controls if the useEffect do anything, the point is the hook returns a function that flips trigger value which triggers the useEffect only when you call this function. The problem is the useEffect's cleanup function is called during the api call even though the component is clearly still mounted.

The cleanup function seems to be fired because I set the value of trigger based on its previous value, when I set trigger to a fixed value the cleanup function is not called but I lose my functionality

const fetchReducer = (state, action) => {
    switch (action.type) {
        case 'FETCH_TRIGGER':
            return {
                ...state,
                trigger: !state.trigger
            }
        case 'FETCH_INIT':
            return {
                ...state,
                isLoading: true,
                isError: false
            };
        case 'FETCH_SUCCESS':
            return {
                ...state,
                isLoading: false,
                isError: false,
                datas: action.payload,
            };
        case 'FETCH_FAILURE':
            return {
                ...state,
                isLoading: false,
                isError: true,
            };
        default:
            throw new Error();
    }
}

const useFetchApi = (query, initialData = []) => {
    let isCancelled = false;
    const [state, dispatch] = useReducer(fetchReducer, {
        isLoading: false,
        isError: false,
        datas: initialData,
        trigger: false
    });
    const triggerFetch = _ => dispatch({ type: 'FETCH_TRIGGER' });
    const cancel = _ => { console.log("canceling");isCancelled = true };

    useEffect(_ => {
        if (!state.trigger)
            return;
        triggerFetch();
        (async _ => {
            dispatch({ type: 'FETCH_INIT' });
            try {
                const datas = await query();
                if (!isCancelled) { //isCancelled is true at this point
                    dispatch({ type: 'FETCH_SUCCESS', payload: datas })
                }
            } catch (err) {
                if (!isCancelled) {
                    dispatch({ type: 'FETCH_FAILURE', payload: err })
                }
            }
        })();
        return cancel;
    }, [state.trigger]);
    return { ...state, triggerFetch};
}

Usage:

function MyComponent () {
    const { datas, isLoading, isError, triggerFetch } = useFetchApi(query);
    return (
        <form onSubmit={event => {event.preventDefault(); triggerFetch()}}>
...
3
  • 1
    The cleanup function is invoked every time the effect is run again as well as on unmount, and in your case it will be run every time state.trigger changes. Commented Mar 27, 2019 at 11:53
  • 2
    The cleanup function returned from an effect is only ever explicitly called on unmount if you pass an empty array as the second argument. Otherwise, the clean up function will be called whenever something in the dependency array changes. Commented Mar 27, 2019 at 11:53
  • 3
    You could add another use effect that didn't do anything except for return that cancel function and have it with an empty array dependency that would mimic componentWillUnmount like useEffect(() => cancel, []) Commented Mar 27, 2019 at 12:00

4 Answers 4

4

A local variable can be used inside the useEffect callback. Credit to @gaearon

https://codesandbox.io/s/k0lm13kwxo

  useEffect(() => {
    let ignore = false;

    async function fetchData() {
      const result = await axios('https://hn.algolia.com/api/v1/search?query=' + query);
      if (!ignore) setData(result.data);
    }

    fetchData();
    return () => { ignore = true; }
  }, [query]);
Sign up to request clarification or add additional context in comments.

Comments

3

2 things to keep in mind:

  1. The clean-up callback runs every time the useEffect runs; that excludes the component's mount and includes the component's unmount. If you want to simulate a componentWillUnMount, use a useEffect with an empty array of dependencies.
  2. The clean-up callback runs before the rest of the code inside the useEffect. This is important to remember for a useEffect that has dependencies since it will be called when any of the dependencies changes and both the clean-up callback and the rest of the code inside the effect are run. This is the componentDidUpdate behavior.

If you ask me, the name clean-up is misleading. The callback actually runs before the rest of the code in componentDidUpdate scenarios, so it's not as much clean-up as it is prep in that regard. The title clean-up is only deserved on unmount.

Comments

2

Solution by Tom Finney from the comments:

You could add another use effect that didn't do anything except for return that cancel function and have it with an empty array dependency that would mimic componentWillUnmount like useEffect(() => cancel, [])

const useFetchApi = (query, initialData = []) => {
    let isCancelled = false;
    const [state, dispatch] = useReducer(fetchReducer, {
        isLoading: false,
        isError: false,
        datas: initialData,
        trigger: false
    });
    const triggerFetch = _ => dispatch({ type: 'FETCH_TRIGGER' });
    const cancel = _ => { console.log("canceling");isCancelled = true };

    useEffect(_ => {
        if (!state.trigger)
            return;
        triggerFetch();
        (async _ => {
            dispatch({ type: 'FETCH_INIT' });
            try {
                const datas = await query();
                if (!isCancelled) {
                    dispatch({ type: 'FETCH_SUCCESS', payload: datas })
                }
            } catch (err) {
                if (!isCancelled) {
                    dispatch({ type: 'FETCH_FAILURE', payload: err })
                }
            }
        })();
    }, [state.trigger]);
    useEffect(_=> cancel, []) //remove return cancel from useEffect and replace by this

    return { ...state, triggerFetch};
}

2 Comments

And does this actually work? Shouldn't it be useEffect(()=> () => cancel(), [])?
@Gatoyu I don't think this solution will successfully cancel on unmount. The cancel function in the second effect will always be the cancel from the initial render which will change the isCancelled variable from the initial render. Calling triggerFetch will trigger a re-render which means the first effect (the one that calls query) will be using an isCancelled from the re-render which will not be changed by the second effect. This is why a ref is needed.
1

Currently, you are triggering a state change which will trigger a re-render which will trigger an effect which will then call your API. All you really want to do is call your API.

In the sample code below, I've changed triggerFetch to actually execute the query and removed the trigger state. I added an effect without dependencies to allow cancelling on unmount. I also changed the cancel approach to use a ref rather than a local variable so that it will persist across re-renders.

import { useReducer, useEffect, useRef } from "react";

const fetchReducer = (state, action) => {
  switch (action.type) {
    case "FETCH_INIT":
      return {
        ...state,
        isLoading: true,
        isError: false
      };
    case "FETCH_SUCCESS":
      return {
        ...state,
        isLoading: false,
        isError: false,
        datas: action.payload
      };
    case "FETCH_FAILURE":
      return {
        ...state,
        isLoading: false,
        isError: true
      };
    default:
      throw new Error();
  }
};

const useFetchApi = (query, initialData = []) => {
  const cancelledRef = useRef(false);
  const [state, dispatch] = useReducer(fetchReducer, {
    isLoading: false,
    isError: false,
    datas: initialData,
    trigger: false
  });
  const triggerFetch = async _ => {
    dispatch({ type: "FETCH_INIT" });
    try {
      const datas = await query();
      if (!cancelledRef.current) {
        dispatch({ type: "FETCH_SUCCESS", payload: datas });
      }
    } catch (err) {
      if (!cancelledRef.current) {
        dispatch({ type: "FETCH_FAILURE", payload: err });
      }
    }
  };

  useEffect(_ => {
    return _ => {
      console.log("canceling");
      cancelledRef.current = true;
    };
  }, []);
  return { ...state, triggerFetch };
};

export default useFetchApi;

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.