Unnecessary rerenders using Apollo + useContext for Authentication (SSR) - javascript

I'm experiencing rerenders seemingly as a result of useQuery being called when I don't want it to be.
First load, isAuthenticated's state is instantiated via a function that makes a query to the server to get the userId based on the attached header.
If a valid header is present, isAuthenticated is set to true and therefore useMemo is called (as isAuthenticated is a dependency). useMemo calls setUser, which puts the userId and role into the context, to be passed down through the AuthProvider (in _app, wrapping everything but the ApolloProvider).
I use the userId in the Navbar (via useContext) to get the avatar (and other things in the rest of the app). What I've noticed is that any time I click on something (for example, a separate query filtering products), the CURRENT_USER query in _app is called. And this manifests as the Navbar losing the useId data passed to it for a second, and then reverts back. So, everytime I click on things, my Navbar is rerendering. I don't understand why the CURRENT_USER query keeps being called...
For context, I use the same setup in another app that does not have SSR without issue.
auth.context.js
import React, { useState, useEffect, useMemo} from "react";
import { CURRENT_USER } from "graphql/query/customer.query";
import { gql, useQuery } from "#apollo/client";
import { isBrowser } from "components/helpers/isBrowser";
export const AuthContext = React.createContext({});
export const AuthProvider = ({ children }) => {
const { data, error, loading } = useQuery(CURRENT_USER, {
onCompleted: () => {
console.log("called query", data);
},
ssr: true,
});
const isValidToken = () => {
if (isBrowser) {
const token = localStorage.getItem("token");
if (error) {
return false;
}
if (!loading && !data) {
return false;
}
if (token && data?.currentUser) {
console.log(data.currentUser);
return true;
}
if (!token) {
return false;
}
}
};
const [isAuthenticated, makeAuthenticated] = useState(isValidToken());
const [user, setUser] = useState({});
function authenticate() {
console.log(isAuthenticated);
makeAuthenticated(isValidToken());
}
useEffect(() => {
console.log("attempted to run");
if (data && isAuthenticated) {
setUser(data.currentUser);
console.log("user", user);
}
}, [isAuthenticated]);
function signout() {
makeAuthenticated(false);
localStorage.removeItem("token");
}
return (
<AuthContext.Provider
value={{
isAuthenticated,
authenticate,
signout,
user,
}}
>
{children}
</AuthContext.Provider>
);
};
"#apollo/client": "^3.0.0-beta.44",
"next": "^9.3.6",
"react": "^16.13.1",

Related

custom hook returning values first null and then user for React Native and Firebase integration

I know the reason why this is haapening, but what I want is a solution for this.
Code:
useAuth.hook.ts
import { useEffect, useState } from "react";
import { getAuth, onAuthStateChanged, User } from "firebase/auth";
const auth = getAuth();
export function useAuth() {
const [user, setUser] = useState<User>();
useEffect(() => {
const unsubscribeFromAuthStateChanged = onAuthStateChanged(auth, (user) => {
if (user) {
// User is signed in, see docs for a list of available properties
// https://firebase.google.com/docs/reference/js/firebase.User
setUser(user);
} else {
// User is signed out
setUser(undefined);
}
});
return unsubscribeFromAuthStateChanged;
}, []);
return user;
}
usage:
const user = useAuth();
// return user ? <LandingScreen /> : <AuthStack />;
if (user) {
return <UserStack />;
} else {
return <AuthStack />;
}
value returned first time: undefined
value returned second time: user from firebase
I know the reson that useEffect renders twice. But is there a solution for this. Because of this in react native app login screen is rendered first and then after few milliseconds main screen.
I looked at multiple answers on Stackoverflow and github issue. I need a workaround this thing so only main screen is rendered.
As your code currently exist, you will always initially render your AuthStack, as the initial value of user is undefined. You could add an additional piece of state to useAuth:
import { useEffect, useState } from "react";
import { getAuth, onAuthStateChanged, User } from "firebase/auth";
const auth = getAuth();
export function useAuth() {
const [user, setUser] = useState<User>();
const [isLoading,setIsLoading] = useState(true);
useEffect(() => {
const unsubscribeFromAuthStateChanged = onAuthStateChanged(auth, (user) => {
if (user) {
// User is signed in, see docs for a list of available properties
// https://firebase.google.com/docs/reference/js/firebase.User
setUser(user);
} else {
// User is signed out
setUser(undefined);
}
setIsLoading(false);
});
return unsubscribeFromAuthStateChanged;
}, []);
return {user, isLoading};
}
Usage:
const {user, isLoading} = useAuth();
// return user ? <LandingScreen /> : <AuthStack />;
if(isLoading){
return <Text>Loading...</Text>
}
if (user) {
return <UserStack />;
} else {
return <AuthStack />;
}
While this won't fix the fact that user is initial undefined, it will prevent going to the AuthStack while auth is confirming the user. If you are willing to use different forms of state management you could jotai to store the user. It stores state as atoms independent of components, meaning easy and simple global state. It also have an util function atomWithStorage which reads from storage for the initial value, and rewrites storage on atom updates:
import { useEffect, useState } from "react";
import { getAuth, onAuthStateChanged, User } from "firebase/auth";
import AsyncStorage from "#react-native-async-storage/async-storage";
const auth = getAuth();
// configure atomWithStorage to use AsyncStorage
export const userAtom = atomWithStorage<user>(
"#firebaseUser",
undefined,
{
getItem: (key) => {
return AsyncStorage.getItem(key)
.then((str) => JSON.parse(str))
.catch((err) => {
console.log("Error retrieving value:", err);
return undefined;
});
},
setItem: (key, newValue) => {
return AsyncStorage.setItem(key, JSON.stringify(user)).catch(
(err) => {
console.log("Error storing value:", err);
}
);
},
removeItem: (key) => AsyncStorage.removeItem(key),
delayInit: true,
}
);
export function useAuth() {
const [user, setUser] = useAtom<User>();
const [isLoading,setIsLoading] = useState(true);
const [isAuthorized, setIsAuthorized] = useState(Boolean(user))
useEffect(() => {
const unsubscribeFromAuthStateChanged = onAuthStateChanged(auth, (user) => {
if (user) {
// User is signed in, see docs for a list of available properties
// https://firebase.google.com/docs/reference/js/firebase.User
setUser(user);
} else {
// overwriting user here will cause AsyncStorage to overwrite
}
setIsLoading(false);
setIsAuthorized(Boolean(user))
});
return unsubscribeFromAuthStateChanged;
}, []);
return {user, isLoading, isAuthorized};
}
Usage:
const {user, isLoading, isAuthorized} = useAuth();
// return user ? <LandingScreen /> : <AuthStack />;
if(isLoading){
return <Text>Loading...</Text>
}
else if (isAuthorized) {
return <UserStack />;
} else {
return <AuthStack />;
}

Prevent Router from Loading Page Briefly before Redirect

I have a session context for my NextJS application where anyone accessing /app/ directory pages have to go through an authorization check prior to allowing the user to access the page.
While my logic works in redirecting users without proper authentication, it is a bit glitchy because when someone navigate to the URL, /app/profile/ the page briefly loads before being redirected by Router.
I am wondering what is the best way to have this check happen prior to router loading the unauthorized page and redirecting them to the /login/ page.
Here are the steps in the authorization check:
Check is the user object has a property, authorized
Query the server for a session token
if the object from the server request comes back with authorized = false, then redirect user to /login/
Here is the code:
import React, { createContext, useContext, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import axios from 'axios'
export const SessionContext = createContext(null);
const AppSession = ({ children }) => {
const router = useRouter()
const routerPath = router.pathname;
const [user, setUser] = useState({ user_id: '', user_email: '', user_avatar: ''})
useEffect(()=> {
// Check for populated user state if pages are accessed with the path /app/
if (routerPath.includes("/app/")){
if (user){
if(user.authenticated === undefined){
// Check if user session exists
axios.get('/api/auth/session/')
.then(res => {
const data = res.data;
// Update user state depending on the data returned
setUser(data)
// If user session does not exist, redirect to /login/
if (data.authenticated === false){
router.push('/login/')
}
})
.catch(err => {
console.log(err)
});
}
}
}
}, [])
return (
<SessionContext.Provider value={{user, setUser}}>
{children}
</SessionContext.Provider>
)
}
export const getUserState = () => {
const { user } = useContext(SessionContext)
return user;
}
export const updateUserState = () => {
const { setUser } = useContext(SessionContext)
return (user) => {
setUser(user);
}
}
export default AppSession;
Since user.authenticated isn't defined in the initial user state you can conditionally render null or some loading indicator while user.authenticated is undefined. Once user.authenticated is defined the code should either redirect to "/login" or render the SessionContext.Provider component.
Example:
const AppSession = ({ children }) => {
const router = useRouter();
const routerPath = router.pathname;
const [user, setUser] = useState({ user_id: '', user_email: '', user_avatar: ''});
...
if (user.authenticated === undefined) {
return null; // or loading indicator/spinner/etc
}
return (
<SessionContext.Provider value={{ user, setUser }}>
{children}
</SessionContext.Provider>
);
};
Check out getServerSideProps, redirects in getServerSideProps and this article.
In your client-side, if you export the NextJS function definition named getServerSideProps from a page, NextJS pre-renders the page on each request using the data returned by getServerSideProps.
In other words, you can use getServerSideProps to retrieve and check the user while pre-rendering the page and then choose to redirect instead of render if your condition is not met.
Here is an example.
function Page({ data }) {
// Render data...
}
export async function getServerSideProps(context) {
const { req, res } = context;
try {
// get your user
if (user.authenticated === undefined) {
return {
redirect: {
permanent: false,
destination: `/`,
},
};
}
return {
props: {
// any static props you want to deliver to the component
},
};
} catch (e) {
console.error("uh oh");
return;
}
}
Good luck!

Keep state in react inside context after logged in?

I'm coding a web react app with sign in. In my server side I'm using express, jwt and sending a httpOnly cookie with the token when succesfully log in. When user logs in, I'm trying to keep state in the client (e.x, loggedIn = true) inside of a context, but every time that context is rendered it comes back to default state (undefined). How could i keep that state in memory?
My user route, that works as expected (backend):
users.post('/login',async (req, res) => {
try {
const {userName,userPass} = req.body
const u = await models.User.findOne({
userid: userName
})
if (!u) res.status(404).end()
if (bcrypt.compare(userPass,u.password)) {
// JWT TOKEN
const t = c_auth(u._id)
res.status(200).cookie("session",t,{
httpOnly:true
}).end()
} else {
res.status(404).end()
}
} catch (e) {
console.log({'ERROR':e})
res.status(500).end()
}
})
My user provider that returns true when request is ok (client):
get: async (user,pass) => {
try {
const req = await axios.post('/users/login',{
userName: user,
userPass: pass
})
if (req.status === 200) {
return true
} else {
return false
}
} catch (e) {
console.log({'ERROR':e})
return false
}
}
Login submit function (client):
import {useAuth} from '../../../../contexts/AuthContext.js'
const {setLoggedIn} = useAuth()
const handleLogin = async (e) => {
if (await users.get(data.userName,data.userPass)) {
setLoggedIn(true)
// ^---> Trying to set loggedIn state to true in context
window.location.replace('/')
} else {
alert(`Incorrect.`)
}
}
Auth context:
const AuthContext = createContext()
export function useAuth() {
return useContext(AuthContext)
}
export function AuthProvider ({children}) {
const [loggedIn,setLoggedIn] = useState()
console.log(loggedIn)
// ^---> Getting true after login,
// undefined (default useState) after re-render
const value = {
loggedIn,
setLoggedIn
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
App.js:
import {AuthProvider} from './contexts/AuthContext'
function App() {
return (
<AuthProvider>
<div className="App">
<Navigation/>
<main>
<Router>
<Routes>
...Routes
</Routes>
</Router>
</main>
</div>
</AuthProvider>
)
}
I suppose that react memo could be the solution, but I don't understand quite well how it works. Also, is it correct not to use the setLoggedIn in the AuthContext itself? I tried to call the login or sign up function (second piece of code) from the AuthContext, but can't set state since its unmounted. Would need to do that inside a useEffect and that's not what I'm looking for since I wouldn`t be able to export that function. All help is appreciated.
EDIT: fixed
The problem solved after changing window.location.replace to the useNavigate hook from react-router-dom, causing a refresh:
import {useAuth} from '../../../../contexts/AuthContext.js'
import {useNavigate} from 'react-router-dom'
const {setLoggedIn} = useAuth()
const navigate = useNavigate()
const handleLogin = async (e) => {
if (await users.get(data.userName,data.userPass)) {
setLoggedIn(true)
navigate('/')
} else {
alert(`Incorrect.`)
}
}
Also in my navbar I was using <a href> tags instead of <Link to> from 'react-router-dom'. That fixes the problem when I go to a different page from the navbar, so it doesn't 'refresh'.

React useState in context does not update

I'm using React to create a login page which after logging in should keep track if the user is logged in. I figured using the react context and state hook would be easier than using something as big and complex as Redux.
I created a login function which works, and after a successfull login it should update my state in my context. The login works (i get a status 200) but my state is not updated in my context.
My 'AuthContext.jsx' looks like this
import React, { createContext, useState } from "react";
import { login, register } from "../API/apiMethods";
export const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [authenticated, setAuthenticated] = useState(false);
const authSetter = (state) => {
setAuthenticated(state);
};
const authGetter = () => {
return authenticated;
};
return (
<AuthContext.Provider
value={{
authSetter,
authGetter,
login,
register,
}}
>
{children}
</AuthContext.Provider>
);
};
My login function looks like this
/**
* #description Handles login
* #param {String} email
* #param {String} password
* #returns {Boolean}
*/
export const login = async(email, password) => {
try {
let authenticated = false;
await fetch(BASE_URL + "login", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
email,
password
}),
}).then((res) => {
authenticated = res.status === 200 ? true : false;
});
return authenticated;
} catch (err) {
console.log(err);
return false;
}
};
And in my login form i try to update the authentication boolean after a successfull login
const {
login,
authSetter,
authGetter
} = useContext(AuthContext);
const submit = async(e) => {
e.preventDefault();
await login(email, password)
.then((authSuccess) => {
if (authSuccess) {
console.log("login successfull");
authSetter(true);
}
})
.then(() => console.log(authGetter()));
};
With this code i expected the console output to be a printed string with 'login successfull' and a printed boolean true.
But it seems my state was not updated even though i did call the setter.
I don't know why it won't update, can anyone help me?
This piece of code does exactly what it is supposed to. When you update the state it does not happen immediately.
const submit = async(e) => {
e.preventDefault();
await login(email, password)
.then((authSuccess) => {
if (authSuccess) {
console.log("login successfull");
authSetter(true);
}
})
.then(() => console.log(authGetter()));
};
When you call the authSetter(true);, the state update is queued and once the then callback completes it goes to the next then in the chain which has your authGetter(). Now the state update does not happen immediately as I explained, it is queued. So when the last then callback is executed the state update which is queued has not happened and you still see false which is the old value.
You can refactor your AuthProvider in the following way, there is no need to wrap the setter in a function as it would create a new instance of the function when the state is updated (useState on the other hand returns a memoized value of the setter function) and you can simply return the authenticated state without the getter which again has the same issue.
import React, { createContext, useState } from "react";
import { login, register } from "../API/apiMethods";
export const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [authenticated, setAuthenticated] = useState(false);
return (
<AuthContext.Provider
value={{
setAuthenticated,
authenticated,
login,
register,
}}
>
{children}
</AuthContext.Provider>
);
};
In your form, you can have an extra useEffect to check whether you have logged in successfully. the useEffect will run when the authenticated state has been updated.
const {
login,
setAuthenticated,
authenticated
} = useContext(AuthContext);
const submit = async(e) => {
e.preventDefault();
const authSuccess = await login(email, password);
if (authSuccess) {
console.log("login successfull");
authSetter(true);
}
};
useEffect(() => {
console.log(authenticated);
}, [authenticated]);

How to persist authentication in Next.js using firebase after token expiration and or refreshing the page

I'm building an app with firebase and next.js. I'm having a hard time with firebase's authentication flows. Everything was coming along well until I noticed a bug/error. If I leave my computer logged in to the app for a while and whenever I refresh the page, It seems like both cause me to be redirected back to my login page. I can see why a refresh would trigger my redirect, as there may not be enough time for the onAuthStateChange to be checked in time for const { user } = useAuth() to run so since there is no user at initial page load (after refresh.) It will go in to the { else } causing me to redirect at this point. But the funny thing is that if I simply click my dashboard (a protected page) link, I'm still authenticated. No redirections. Below is my code for my auth component:
AuthComp.js:
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useAuth } from "../../context/AuthContext";
function LoadingScreen() {
return <div className="fixed top-0 right-0 h-screen w-screen z-50 flex justify-center items-center">
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-gray-900"></div>
</div>
}
export function AuthComp(props) {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const router = useRouter();
const { user } = useAuth();
useEffect(() => {
let out;
if (user) {
setIsAuthenticated(true)
return () => out ? out() : null
} else {
router.push('/auth')
}
return () => out;
}, [user])
if (!user && (!isAuthenticated)) {
return <LoadingScreen />;
}
return <props.Component user={user} />
};
Here is my code for my auth context file: AuthContext.js:
import React, { useState, useEffect, createContext, useContext } from 'react'
import { fbase } from '../utils/auth/firebaseClient'
export const AuthContext = createContext()
export default function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loadingUser, setLoadingUser] = useState(true) // Helpful, to update the UI accordingly.
useEffect(() => {
// Listen authenticated user
const unsubscriber = fbase.auth.onAuthStateChanged(async (user) => {
try {
if (user) {
setUser(user);
} else {
setUser(null)
return;
}
} catch (error) {
// Most probably a connection error. Handle appropriately.
console.log('an error occurred', error);
} finally {
setLoadingUser(false)
}
})
// Unsubscribe auth listener on unmount
return () => unsubscriber()
}, [])
return (
<AuthContext.Provider value={{ user, setUser, loadingUser }}>
{children}
</AuthContext.Provider>
)
}
// Custom hook that shorthands the context!
export const useAuth = () => useContext(AuthContext)
there may not be enough time for the onAuthStateChange to be checked in time
The first user result from onAuthStateChanged is definitely not guaranteed to happen immediately. You should expect that the first callback will take some time, as the user's persisted token is first loaded then verified.
Before the callback triggers the first time, you should assume an "unknown" state for the user. The user is neither signed in nor signed out until that first callback. I suggest writing your app with this trinary state in mind. (Related, firebase.auth().currentUser will always be null when a page first loads.) To read more about this behavior, I suggest reading this blog post.

Categories