Search code examples
reactjsreact-hooksreact-router-domreact-context

React context requires 2 state updates for consumers to re-render


So I have a straight forward app that requires you to login to see a dashboard. I've based my auth flow off of https://reactrouter.com/web/example/auth-workflow which in return bases their flow off of https://usehooks.com/useAuth/

Currently, when a user logs in it calls a function within the context provider to sign in and that function updates the state of the context with the user data retrieved from the server. This is reflected in React dev tools under my context providers as shown in the teacher attribute:
enter image description here

When the context state has successfully been updated I then use useHistory().push("dashboard/main") from the react-router API to go to the dashboard page. The dashboard is a consumer of the context provider but the teacher value is still null when I try rendering the page- even though React dev tools clearly shows the value has been updated. When I log in again, the dashboard will successfully render, so, ultimately, it takes two context updates in order for my Dashboard to reflect the changes and render. See my following code snippets (irrelevant code has been redacted):

App.js

const App = () => {

return (
    <AuthProvider>        
        <div className="App">
            <Switch>
                <Route path="/" exact >
                    <Home setIsFetching={setIsFetching} /> 
                </Route>
                <ProtectedRoute path="/dashboard/:page" >
                    <Dashboard
                        handleToaster={handleToaster}
                    />
                </ProtectedRoute>
                <ProtectedRoute path="/dashboard">
                    <Redirect to="/dashboard/main"/>
                </ProtectedRoute>
                <Route path="*">
                    <PageNotFound/>
                </Route>
            </Switch>
            <Toaster display={toaster.display} setDisplay={(displayed) => setToaster({...toaster, display: displayed})}>{toaster.body}</Toaster>
        </div>
    </AuthProvider>   
);}

AuthProvider.js

const AuthProvider = ({children}) => {
const auth = useProvideAuth();

return(
    <TeacherContext.Provider value={auth}>
        {children}
    </TeacherContext.Provider>
);};

AuthHooks.js

export const TeacherContext = createContext();

export const useProvideAuth = () => {
    const [teacher, setTeacher] = useState(null);
    const memoizedTeacher = useMemo(() => ({teacher}), [teacher]);

const signin = (data) => {
    fetch(`/api/authenticate`, {method: "POST", body: JSON.stringify(data), headers: JSON_HEADER})
    .then(response => Promise.all([response.ok, response.json()]))
    .then(([ok, body]) => {
        if(ok){
            setTeacher(body);
        }else{
            return {...body};
        }
    })
    .catch(() => alert(SERVER_ERROR));
};

const register = (data) => {
    fetch(`/api/createuser`, {method: "POST", body: JSON.stringify(data), headers: JSON_HEADER})
    .then(response => Promise.all([response.ok, response.json()]))
    .then(([ok, body]) => {
        if(ok){
            setTeacher(body);
        }else{
            return {...body};
        }
    })
    .catch(() => alert(SERVER_ERROR));
};

const refreshTeacher = async () => {
    let resp = await fetch("/api/teacher");
    if (!resp.ok)
        throw new Error(SERVER_ERROR);
    else
        await resp.json().then(data => {
            setTeacher(data);
        });
};

const signout = () => {
    STORAGE.clear();
    setTeacher(null);
};

return {
    ...memoizedTeacher,
    setTeacher,
    signin,
    signout,
    refreshTeacher,
    register
};
};

export const useAuth = () => {
    return useContext(TeacherContext);
};

ProtectedRoute.js

const ProtectedRoute = ({children, path}) => {
    let auth = useAuth();
    return (
        <Route path={path}>
        {
            auth.teacher 
                ? children
                : <Redirect to="/"/>
        }
        </Route>
);
};

Home.js

const Home = ({setIsFetching}) => { 
let teacherObject = useAuth();
let history = useHistory();



const handleFormSubmission = (e) => {
    e.preventDefault();
    const isLoginForm = modalContent === "login";
    const data = isLoginForm ? loginObject : registrationObject;
    const potentialSignInErrors = isLoginForm ? 
    teacherObject.signin(data) : teacherObject.register(data);
    if(potentialSignInErrors)
        setErrors(potentialSignInErrors);
    else{
         *******MY ATTEMPT TO PUSH TO THE DASHBOARD AFTER USING TEACHEROBJECT.SIGNIN********
        history.replace("/dashboard/main");
    }
};

};)};

Dashboard.js

const Dashboard = ({handleToaster}) => {
const [expanded, setExpanded] = useState(true);
return (
    <div className={"dashboardwrapper"}>
        <Sidebar
            expanded={expanded}
            setExpanded={setExpanded}
        />
        <div className={"dash-main-wrapper"}>
        <DashNav/>
            <Switch>
                <Route path="/dashboard/classroom" exact>
                    <Classroom handleToaster={handleToaster} />
                </Route>
                <Route path="/dashboard/progressreport" exact>
                    <ProgressReport/>
                </Route>
                <Route path="/dashboard/help" exact>
                    <Help/>
                </Route>
                <Route path="/dashboard/goalcenter" exact>
                    <GoalCenter />
                </Route>
                <Route path="/dashboard/goalcenter/create" exact>
                    <CreateGoal />
                </Route>
                <Route path="/dashboard/profile" exact>
                    <Profile />
                </Route>
                <Route path="/dashboard/test" exact>
                    <Test />
                </Route>
                <Route path="/dashboard/main" exact>
                    <DashMain/>
                </Route>
            </Switch>
        </div>
    </div>
);
};

Let me know if there's anything that stands out to you that would be preventing my Dashboard from rendering with the updated context values the first time instead of having to update it twice. Do let me know if you need more insight into my code or if I missed something- I'm also fairly new to SO. Also, any pointers on the structure of my app would be greatly appreciated as this is my first React project. Thank you.


Solution

  • I think the problem is in the handleFormSubmission function:

    const handleFormSubmission = (e) => {
        e.preventDefault();
        const isLoginForm = modalContent === "login";
        const data = isLoginForm ? loginObject : registrationObject;
        const potentialSignInErrors = isLoginForm ? 
        teacherObject.signin(data) : teacherObject.register(data); 
        if(potentialSignInErrors)
            setErrors(potentialSignInErrors);
        else{
            history.replace("/dashboard/main");
        }
    };
    

    You call teacherObject.signin(data) or teacherObject.register(data) and then you sequentially change the history state.

    The problem is that you can't be sure the teacher state has been updated, before history.replace is called.

    I've made a simplified version of your home component to give an example how you could approach the problem

    function handleSignin(auth) {
      auth.signin("data...");
    }
    
    const Home = () => {
      const auth = useAuth();
    
      useEffect(() => {
        if (auth.teacher !== null) {
          // state has updated and teacher is defined, do stuff
        }
      }, [auth]);
    
      return <button onClick={() => handleSignin(auth)}>Sign In</button>;
    };
    

    So when auth changes, check if teacher has a value and do something with it.