HarrySIV
HarrySIV

Reputation: 105

Typescript with React: Using custom hook in useEffect

I'm trying to use a hook inside of a useEffect call to run only once (and load some data). I keep getting the error that I can't do that (even though I've done the exact same thing in another app, not sure why 1 works and the other doesn't), and I understand I may be breaking the Rules of Hooks... so, what do I do instead? My goal was to offload all the CRUD operation logic into a simple hook. Here's MenuItem, the component trying to use the hook to get the data.

const MenuItem = () => {
  const [ID, setID] = useState<number | null>(null);
  const [menu, setMenu] = useState<Item[]>([]);
  const { getMenu, retrievedData } = useMenu();

  //gets menu items using menu-hook
  useEffect(() => {
    getMenu();
  }, []);

  //if menu is retrieved, setMenu to retrieved data
  useEffect(() => {
    if (retrievedData.length) setMenu(retrievedData);
  }, []);

  //onClick of menu item, displays menu item description
  const itemHandler = (item: Item) => {
    if (ID === null || ID !== item._id) {
      setID(item._id);
    } else {
      setID(null);
    }
  };
return ...
};

And here's getMenu, the custom hook that handles the logic and data retrieval.

const useMenu = () => {
  const backendURL: string = 'https://localhost:3001/api/menu';
  const [retrievedData, setRetrievedData] = useState<Item[]>([]);

  const getMenu = async () => {
    await axios
      .get(backendURL)
      .then((fetchedData) => {
        setRetrievedData(fetchedData.data.menu);
      })
      .catch((error: Error) => {
        console.log(error);
        setRetrievedData([]);
      });
  };

  return { getMenu, retrievedData };
};

export default useMenu;

And finally here's the error.

Invalid hook call. Hooks can only be called inside of the body of a function component.

I'd like to add I'm also using Typescript which isn't complaining right now.

Upvotes: 1

Views: 1951

Answers (2)

HarrySIV
HarrySIV

Reputation: 105

After working on this project for some time I've also found another solution that is clean and I believe doesn't break the rule of hooks. This requires me to set up a custom http hook that uses a sendRequest function to handle app wide requests. Let me make this clear, THIS IS NOT A SIMPLE SOLUTION, I am indeed adding complexity, but I believe it helps since I'll be making multiple different kinds of requests in the app. This is the sendRequest function. Note the useCallback hook to prevent unnecessary rerenders

const sendRequest = useCallback(
    async (url: string, method = 'GET', body = null, headers = {}) => {
      setIsLoading(true);
      const httpAbortCtrl = new AbortController();
      activeHttpRequests.current.push(httpAbortCtrl);

      try {
        const response = await fetch(url, {
          method,
          body,
          headers,
          signal: httpAbortCtrl.signal,
        });
        const responseData = await response.json();

        activeHttpRequests.current = activeHttpRequests.current.filter(
          (reqCtrl) => reqCtrl !== httpAbortCtrl
        );

        if (!response.ok) throw new Error(responseData.message);
        setIsLoading(false);
        return responseData;
      } catch (error: any) {
        setError(error);
        setIsLoading(false);
        throw error;
      }
    },
    []
  );

Here's the new useMenu hook, note I don't need to return getMenu as every time sendRequest is used in my app, getMenu will automatically be called.

export const useMenu = () => {
  const { sendRequest } = useHttpClient();
  const [menu, setMenu] = useState<MenuItem[]>([]);
  const [message, setMessage] = useState<string>('');

  useEffect(() => {
    const getMenu = async () => {
      try {
        const responseData = await sendRequest(`${config.api}/menu`);
        setMenu(responseData.menu);
        setMessage(responseData.message);
      } catch (error) {}
    };
    getMenu();
  }, [sendRequest]);

  return { menu, message };
};

Good luck

Upvotes: 0

gerrod
gerrod

Reputation: 6627

There's a few things you can do to improve this code, which might help in future. You're right that you're breaking the rule of hooks, but there's no need to! If you move the fetch out of the hook (there's no need to redefine it on every render) then it's valid not to have it in the deps array because it's a constant.

I'd also make your useMenu hook take care of all the details of loading / returning the loaded value for you.

const fetchMenu = async () => {
  const backendURL: string = 'https://localhost:3001/api/menu';

  try {
    const { data } = await axios.get(backendURL);
    return data.menu;
  } catch (error: AxiosError) {
    console.log(error);
    return [];
  };
}

export const useMenu = () => {
  const [items, setItems] = useState<Item[]>([]);

  useEffect(() => {
    fetchMenu.then(result => setItems(result);
  }, []);

  return items;
};

Now you can consume your hook:

const MenuItem = () => {
  const [ID, setID] = useState<number | null>(null);

  // Now this will automatically be an empty array while loading, and
  // the actual menu items once loaded.
  const menu = useMenu();

  // --- 8< ---
  
  return ...
};

A couple of other things -

  • Try to avoid default exports, because default exports are terrible.
  • There are a lot of packages you can use to make your life easier here! react-query is a good one to look at as it will manage all the lifecycle/state management around external data
  • Alternatively, check out react-use, a collection of custom hooks that help deal with lots of common situations like this one. You could use the useAsync hook to simplify your useMenu hook above:
const backendURL: string = 'https://localhost:3001/api/menu';

const useMenu = () => useAsync(async () => {
  const { data } = await axios.get(backendURL);
  return data.menu;
});

And now to consume that hook:

const MenuItem = () => {
  const { value: menu, loading, error } = useMenu();

  if (loading) {
    return <LoadingIndicator />;
  }

  if (error) {
    return <>The menu could not be loaded</>;
  }

  return ...
};

As well as being able to display a loading indicator while the hook is fetching, useAsync will not give you a memory leak warning if your component unmounts before the async function has finished loading (which the code above does not handle).

Upvotes: 3

Related Questions