2

I'm fairly new to the context API and react hooks beyond useState and useEffect so please bare with me.

I'm trying to create a custom useGet hook that I can use to GET some data from the backend then store this using the context API, so that if I useGet again elsewhere in the app with the same context, it can first check to see if the data has been retrieved and save some time and resources having to do another GET request. I'm trying to write it to be used generally with various different data and context.

I've got most of it working up until I come to try and dispatch the data to useReducer state and then I get the error:

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

I know I'm probably breaking the rules of hooks with my call to dispatch, but I don't understand why only one of my calls throws the error, or how to fix it to do what I need. Any help would be greatly appreciated.

commandsContext.js

import React, { useReducer, useContext } from "react";

const CommandsState = React.createContext({});
const CommandsDispatch = React.createContext(null);

function CommandsContextProvider({ children }) {
  const [state, dispatch] = useReducer({});
  return (
    <CommandsState.Provider value={state}>
      <CommandsDispatch.Provider value={dispatch}>
        {children}
      </CommandsDispatch.Provider>
    </CommandsState.Provider>
  );
}

function useCommandsState() {
  const context = useContext(CommandsState);
  if (context === undefined) {
    throw new Error("Must be within CommandsState.Provider");
  }
  return context;
}

function useCommandsDispatch() {
  const context = useContext(CommandsDispatch);
  if (context === undefined) {
    throw new Error("Must be within CommandsDispatch.Provider");
  }
  return context;
}

export { CommandsContextProvider, useCommandsState, useCommandsDispatch };

useGet.js

import { API } from "aws-amplify";
import { useRef, useEffect, useReducer } from "react";

export default function useGet(url, useContextState, useContextDispatch) {
  const stateRef = useRef(useContextState);
  const dispatchRef = useRef(useContextDispatch);
  const initialState = {
    status: "idle",
    error: null,
    data: [],
  };

  const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case "FETCHING":
        return { ...initialState, status: "fetching" };
      case "FETCHED":
        return { ...initialState, status: "fetched", data: action.payload };
      case "ERROR":
        return { ...initialState, status: "error", error: action.payload };
      default:
        return state;
    }
  }, initialState);

  useEffect(() => {
    if (!url) return;

    const getData = async () => {
      dispatch({ type: "FETCHING" });
      if (stateRef.current[url]) { // < Why doesn't this also cause an error
        const data = stateRef.current[url]; 
        dispatch({ type: "FETCHED", payload: data });
      } else {
        try {
          const response = await API.get("talkbackBE", url);
          dispatchRef.current({ url: response }); // < This causes the error
          dispatch({ type: "FETCHED", payload: response });
        } catch (error) {
          dispatch({ type: "ERROR", payload: error.message });
        }
      }
    };
    getData();
  }, [url]);

  return state;
}

EDIT --

useCommandsState and useCommandsDispatch are imported to this component where I call useGet passing the down.

import {
  useCommandsState,
  useCommandsDispatch,
} from "../../contexts/commandsContext.js";

export default function General({ userId }) {
  const commands = useGet(
    "/commands?userId=" + userId,
    useCommandsState,
    useCommandsDispatch
  );

Why am I only getting an error for the dispatchRef.current, and not the stateRef.current, When they both do exactly the same thing for the state/dispatch of useReducer?

How can I refactor this to solve my problem? To summarise, I need to be able to call useGet in two or more places for each context with the first time it's called the data being stored in the context passed.

Here are various links to things I have been reading, which have helped me to get this far.

How to combine custom hook for data fetching and context?

Updating useReducer 'state' using useEffect

Accessing context from useEffect

https://reactjs.org/warnings/invalid-hook-call-warning.html

jbflow
  • 578
  • 2
  • 16
  • They are passed into useGet as useContextState and useContextDispatch. Named like this so they can be used generally, it's just in this instance there being used for the CommandsContext – jbflow Jul 15 '21 at 18:15
  • I have edited this into my question. – jbflow Jul 15 '21 at 18:22
  • I need to share the state between about 3 components at various nesting levels, and don't want to perform a GET request for each of them. – jbflow Jul 15 '21 at 18:28

1 Answers1

2

I think your problem is because you are using useRef instead of state for storing state. If you useRef for storing state you need to manually tell react to update.

I personally would not use reducer and just stick to the hooks you are familiar with as they fulfill your current requirements. I also think they are the best tools for this simple task and are easier to follow.

Code

useGetFromApi.js

This is a generalized and reusable hook - can be used inside and outside of the context

export const useGetFromApi = (url) => {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!url) return;
    const getData = async () => {      
      try {
        setLoading(true);
        setData(await API.get('talkbackBE', url));
      } catch ({ message }) {
        setError(message);
      } finally {
        setLoading(false); // always set loading to false
      }
    };
    getData();
  }, [url]);

  return { data, error, loading };
};

dataProvider.js

export const DataContext = createContext(null);

export const DataProvider = ({ children, url}) => {
  const { data, error, loading } = useGetFromApi(url);
  return (
    <DataContext.Provider value={{ data, error, loading }}>
      {children}
    </DataContext.Provider>
  );
};

useGet.js

Don't need to check if context is undefined - React will let you know

export const useGet = () => useContext(DataContext);

Usage

Most parent wrapping component that needs access to data. This level doesn't have access to the data - only it's children do!

const PageorLayout = ({children}) => (
 <DataProvider url="">{children}</DataProvider>
)

A page or component that is nested inside of the context

const NestedPageorComponent = () => {
  const {data, error, loading } = useGet();
  if(error) return 'error';
  if(loading) return 'loading';
  return <></>;
}

Hopefully this is helpful!

Note I wrote most of this on Stack in the editor so I was unable to test the code but it should provide a solid example

Sean W
  • 5,663
  • 18
  • 31
  • 1
    Thank you, this is definitely helpful. My only problem is I don't know which component is going to load first. I guess I could just useGetFromAPI at App level and then just useGet on all the components lower down. – jbflow Jul 15 '21 at 19:20
  • 1
    If you just call useGetFromAPI first - you defeat the purpose of the context. You can force the child components to not render until the query is complete in the context or just check if the data is loaded and show a loading component until the query is complete. – Sean W Jul 15 '21 at 19:23
  • 1
    Ah I think I see how this works now. GetFromAPI is in the provider. – jbflow Jul 15 '21 at 19:23
  • useGetFromAPI is intentionally separated from the context file to keep the code simpler to read and make it reusable. You can put the whole hook inside your context - you'd just lose reusability. – Sean W Jul 15 '21 at 19:28
  • Yeah, this answers my question, thanks for your help. I did have to remove `data` from the dependency array, otherwise I was getting an infinite loop. The reason I was using a Reducer was because I read somewhere that it was useful for updating the context, which I need to do on subsequent POST requests to avoid having to GET again. But that is off-topic for this question, so I'l cross that bridge when I come to it :) – jbflow Jul 15 '21 at 19:52
  • 1
    Glad it worked. Reducers have their place, I don't think you will need it for this context. You can add a function to your data provider that can be called to update and expose it to your context. – Sean W Jul 15 '21 at 20:00