Search code examples
reactjsreact-router-domreact-context

React set context and navigate


I have a set of screens (3 for now) that can be accessed only if the user takes certain actions on the previous screen, like clicking on some checkboxes and a few links. I am using react-context to pass this information to these screens.

The essence of the code is below

Consent.tsx

const onConsentButtonClick = () => {
  setMyContext({userConsent: true});
  navigate("postConsent"); // I am using react-router v6. navigate = useNavigate();
}

PostConsent.tsx

useEffect(() => {
  if(!myContext.userConsent) { // userConsent is always undefined on render
    // show errors and fallback to Consent.tsx
  }
}, []);

This does not work because it looks like the PostConsent component is rendered before the context could update. I verified this using a timeout. If I change the button click function as below it works properly.

const onConsentButtonClick = () => {
  setMyContext({userConsent: true});
  setTimeout(() => {
    navigate("postConsent");
  }, 1000);
}

Is there a way to ensure that the context update has finished before navigating to the next screen?

And am I using contexts right? is there a better way to achieve this?

Prop drilling is not an option in my case and neither is passing state via location (because I don't want browser back to work on these set of screens).

Please do suggest if there are better alternative ways to achieve this.

EDIT: Here is my complete context file

const MyContext = createContext(null);

export function MyProvider({ children }) {
    const [myContext, setMyContext] = useState({});
    return (
   <MyContext.Provider value={{myContext, setMyContext}}>
            {children}
    </MyContext.Provider>
    );
}

export const useMyContext = () => {
    return useContext(MyContext);
}

and I use this hook in my components

const {myContext, setMyContext} = useMyContext();

UPDATE: Based on the answer below, I wrote a custom hook to encapsulate the context setting part and the navigation after the context is actually set

type SetContextAndNavigate = {
    context: any,
    navigate: string
}

const defaultHookValues: SetContextAndNavigate = { context: {initialHookValue: "hook"}, navigate: "nowhere" }; 

function useSetContextAndNavigate() {
  const { myContext, setMyContext } = useMyContext();
  const [currentValues, setAndNavigate] = useState(defaultHookValues);
  const navigate = useNavigate();

  const setValuesBeforeNavigation = (args: SetContextAndNavigate) => {
    setAndNavigate(args);
    setMyContext(args.context);
  }

  useEffect(() => {
    if(currentValues.context == myContext) {
        navigate(currentValues.navigate);
    }
  }, [myContext]);

  return setValuesBeforeNavigation;
}

Usage in component:

const setContextAndNavigate = useSetContextAndNavigate();
setContextAndNavigate({context: {...consents}, navigate: "postConsent"});

Solution

  • The way you're using context is mostly correct.

    Problem is as you said, setting state in React is asynchronous so it takes place in the next tick.

    In Vue.js, there's a nextTick() function for that but in React, there's not. The closest to nextTick() in React:

    setMyContext(...);
    setTimeout(() => {
      navigate(...);
    }, 0);
    

    The above works but it's not elegant. A more proper way to handle it is to listen for the state change with useEffect.

    In your case, you want to track the state of user's consent, whether it's not done, consented or not consented. We could use undefined as the "not done" state, for example.

    Like this (inside PostConsent):

    useEffect(() => {
      if (typeof myContext.userConsent === 'boolean') {
        // if consented, do something. otherwise, redirect to failure screen
      } else {
        // waiting... show some loading indicator or simply show loading indicator when page mounts and do nothing here, or simply render `null` until `myContext.userConsent` becomes available
      }
    }, [myContext]); // This is the important line, to listen for `myContext`
    

    Finally, the best approach is to check the context state update before navigating:

    useEffect(() => {
      if (myContext.userConsent) {
        navigate("postConsent");
      }
    }, [myContext]);
    
    ...
    
    const onConsentButtonClick = () => {
      setMyContext({userConsent: true});
    }