React context not updating from after promise completed - reactjs

I have a React context which I am using to manage the authentication within my application. I have done this previously and all seemed OK, but in this application the value of the isAuthenticated property is not being updated. I've tried to replicate using CodeSanbox but I get the expected result.
Essentially, I want the context to hold a value of isAuthenticating: true until the authentication flow has finished, once this has finished I will determine if the user is authenticated by checking isAuthenticated === true && authenticatedUser !== undefined however, the state does not seem to be getting updated.
As a bit of additional context to this, I am using turborepo and next.js.
AuthenticationContext:
import { SilentRequest } from '#azure/msal-browser';
import { useMsal } from '#azure/msal-react';
import { User } from 'models';
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { msal, sendRequest } from 'utils';
interface AuthenticationContextType {
authenticatedUser?: User;
isAuthenticating: boolean;
}
const AuthenticationContext = createContext<AuthenticationContextType>({
authenticatedUser: undefined,
isAuthenticating: true
});
export const AuthenticationProvider = (props: { children: React.ReactNode }) => {
const { accounts, instance } = useMsal();
const [user, setUser] = useState<User>();
const [isAuthenticating, setIsAuthenticating] = useState<boolean>(true);
const [currentAccessToken, setCurrentAccessToken] = useState<string>();
const getUserFromToken = useCallback(async () => {
if (user) {
setIsAuthenticating(false);
return;
}
const userRequest = await sendRequest('me');
if (! userRequest.error && userRequest.data) {
setUser(userRequest.data as User);
}
}, [user]);
const getAccessToken = useCallback(async () => {
if (! currentAccessToken) {
const request: SilentRequest = {
...msal.getRedirectRequest(),
account: accounts[0]
}
const response = await instance.acquireTokenSilent(request);
setCurrentAccessToken(response.accessToken);
}
return getUserFromToken();
}, [accounts, currentAccessToken, getUserFromToken, instance]);
useEffect(() => {
async function initialiseAuthentication() {
await getAccessToken();
setIsAuthenticating(false);
}
initialiseAuthentication();
}, [getAccessToken]);
return (
<AuthenticationContext.Provider value={{ authenticatedUser: user, isAuthenticating }}>
{ props.children }
</AuthenticationContext.Provider>
)
}
export const useAuth = () => {
const context = useContext(AuthenticationContext);
if (context === undefined) {
throw new Error("useAuth was used outside of it's provider.")
}
return context;
}
AuthenticationLayout:
import { useEffect, useState } from 'react';
import { AuthenticationProvider, useAuth } from '../hooks/authentication';
import MsalLayout from './msal-layout';
const AuthenticationLayout = (props: { children: React.ReactNode }) => {
const { isAuthenticating, authenticatedUser } = useAuth();
const wasAuthenticationSuccessful = () => {
return ! isAuthenticating && authenticatedUser !== undefined;
}
const renderContent = () => {
if (! wasAuthenticationSuccessful()) {
return (
<p>You are not authorized to view this application.</p>
)
}
return props.children;
}
if (isAuthenticating) {
return (
<p>Authenticating...</p>
)
}
return (
<MsalLayout>
{ renderContent() }
</MsalLayout>
)
}
export default AuthenticationLayout;
MsalLayout:
import { InteractionType } from '#azure/msal-browser';
import {
AuthenticatedTemplate,
MsalAuthenticationTemplate,
MsalProvider,
} from "#azure/msal-react";
import { msalInstance, msal } from 'utils';
import { AuthenticationProvider } from '../hooks/authentication';
msal.initialize();
const MsalLayout = (props: { children: React.ReactNode }) => {
return (
<MsalProvider instance={msalInstance}>
<MsalAuthenticationTemplate interactionType={InteractionType.Redirect} authenticationRequest={msal.getRedirectRequest()}>
<AuthenticatedTemplate>
<AuthenticationProvider>
{props.children}
</AuthenticationProvider>
</AuthenticatedTemplate>
</MsalAuthenticationTemplate>
</MsalProvider>
)
}
export default MsalLayout;
Theoretically, once the authentication is finished I would expect the props.children to display.

I think that the problem is AuthenticationLayout is above the provider. You have consumed the provider in MsalLayout. Then AuthenticationLayout uses MsalLayout so the AuthenticationLayout component is above the provider in the component tree. Any component that consumes the context, needs to be a child of the provider for that context.
Therefore the context is stuck on the static default values.
Your capture of this scenario in useAuth where you throw an error is not warning you of this as when its outside the context -- context is not undefined, it is instead the default values which you pass to createContext. So your if guard isn't right.
There are some workarounds to checking if its available -- for example you could use undefined in the default context for isAuthenticating and authenticatedUser and then check that. Or you can change them to getters and set the default context version of this function such that it throws an error.

Related

React Native - Authentication - trigger value to change Auth & UnAuth Stack Navigators

Here my App.js
import React, { useEffect } from "react";
import { NavigationContainer } from "#react-navigation/native";
import AuthStore from "./src/stores/AuthStore";
import AuthStackNavigator from "./src/navigation/AuthStackNavigator";
import UnAuthStackNavigator from "./src/navigation/UnAuthStackNavigator";
const App = () => {
useEffect(() => {
console.log("APP JS", AuthStore.userAuthenticated);
}, [AuthStore.userAuthenticated]);
return <NavigationContainer>
{AuthStore.userAuthenticated ? <AuthStackNavigator /> : <UnAuthStackNavigator />}
</NavigationContainer>;
};
export default App;
The AuthStore value of userAuthenticated is computed and updated on auto login or login.
Here AuthStore.js
import { userLogin, userRegister } from "../api/AuthAgent";
import { clearStorage, retrieveUserSession, storeUserSession } from "../utils/EncryptedStorage";
import { Alert } from "react-native";
import { computed, makeObservable, observable } from "mobx";
import { setBearerToken } from "../config/HttpClient";
class AuthStore {
user = {};
token = undefined;
refreshToken = undefined;
decodedToken = undefined;
constructor() {
makeObservable(this, {
token: observable,
refreshToken: observable,
user: observable,
decodedToken: observable,
userAuthenticated: computed,
});
this.autoLogin();
}
async doLogin(body) {
const resp = await userLogin(body);
console.log("AuthStore > userLogin > resp => ", resp);
if (resp.success) {
this.decodedToken = await this.getDecodedToken(resp.token);
this.setUserData(resp);
storeUserSession(resp);
} else {
Alert.alert(
"Wrong credentials!",
"Please, make sure that your email & password are correct",
);
}
}
async autoLogin() {
const user = await retrieveUserSession();
if (user) {
this.setUserData(user);
}
}
setUserData(data) {
this.user = data;
this.token = data.token;
setBearerToken(data.token);
}
get userAuthenticated() {
console.log('AuthStore > MOBX - COMPUTED userAuthenticated', this.user);
if (this.token) {
return true;
} else return false;
}
async logout() {
await clearStorage();
this.user = undefined;
this.token = undefined;
this.refreshToken = undefined;
this.decodedToken = undefined;
}
}
export default new AuthStore();
The main problem is that the AuthStore.userAuthenticated value even when it changes on AuthStore it does not triggered by useEffect of the App.js.
So, when I log in or log out I have to reload the App to trigger the useEffect hook and then the navigators are only updated.
You can use useMemo hook to achive this.
const App = () => {
const [userToken, setUserToken] = useState("")
const authContext: any = useMemo(() => {
return {
signIn: (data: any) => {
AsyncStorage.setValue("token", data.accessToken);
setUserToken(data.accessToken);
},
signOut: () => {
setUserToken("");
AsyncStorage.setValue("token", "");
},
};
}, []);
return (
<AuthContext.Provider value={authContext}>
{userToken.length ? (
<UnAuthStackNavigator />
) : (
<AuthStackNavigator />
)}
)
</AuthContext.Provider>
)
}
AuthContext.ts
import React from "react";
export const AuthContext: any = React.createContext({
signIn: (res: any) => {},
signOut: () => {},
});
Now you can use this functions in any files like this:
export const SignIn = () => {
const { signIn } = useContext(AuthContext);
return (
<Button onPress={() => {signIn()}} />
)
}
If your primary purpose is to navigate in and out of stack if authentication is available or not then asynstorage is the best option you have to first
store token.
const storeToken = async (value) => {
try {
await AsynStorage.setItem("userAuthenticated", JSON.stringify(value));
} catch (error) {
console.log(error);
}
};
("userAuthenticated" is the key where we get the value )
Now go to the screen where you want this token to run turnery condition
const [token, setToken] = useState();
const getToken = async () => {
try {
const userData = JSON.parse(await AsynStorage.getItem("userAuthenticated"))
setToken(userData)
} catch (error) {
console.log(error);
}
};
Now use the token in the state then run the condition:
{token? <AuthStackNavigator /> : <UnAuthStackNavigator />}
or
{token != null? <AuthStackNavigator /> : <UnAuthStackNavigator />}

Why isn't necessary the contextAPI in that case?

I thought that if I want to pass a state in another component, it is possible only through props or with contextAPI (provider etc).
For example:
import { createContext, useState, useContext } from 'react';
const FavContext = createContext();
//now we have a context object
function FavProvider(props) {
//create the provider and its functionality
const shape = {
type: '', //films, planets, people
id: 0, //id of film, or planet, or person
data: {}, //the actual data object
};
const [fav, setFav] = useState(shape);
function updateFav(type, id, data) {
setFav({
type,
id,
data,
});
}
return <FavContext.Provider value={[fav, updateFav]} {...props} />;
}
function useFav() {
//for pages that want to access the context object's value
//custom hook use...
const context = useContext(FavContext);
if (!context) throw new Error('Not inside the Provider');
return context; // [fav, updateFav]
}
export { useFav, FavProvider };
I wrap all the other components with the FavProvider, then I use the useFav wherever I want.
Here is a code without the contextAPI, and it works. Why is that? Why isn't necessary the contextAPI in that case?
import { useEffect, useState } from 'react';
import { getAuth, onAuthStateChanged } from 'firebase/auth';
export default function useAuthStatus() {
const [loggedIn, setLoggedIn] = useState(false);
const [checkingStatus, setCheckingStatus] = useState(true);
useEffect(() => {
const auth = getAuth();
onAuthStateChanged(auth, (user) => {
if (user) {
setLoggedIn(true);
}
setCheckingStatus(false);
});
});
return { loggedIn, checkingStatus };
}
and when I want to use the loggedIn and chekingStatus, I just bring it in like this:
import { Navigate, Outlet } from 'react-router-dom';
import useAuthStatus from '../hooks/useAuthStatus';
import Spinner from './Spinner';
export default function PrivateRoutes() {
const { loggedIn, checkingStatus } = useAuthStatus();
if (checkingStatus) {
return <Spinner />;
}
return loggedIn ? <Outlet /> : <Navigate to='/sign-in' />;
}
Why did you use an array to pass the state through the value in the context API?

How to set the default react context value as data from firestore?

I'm building a workout program planner app, the workout program is handled in the app with a SetProgram context and is updated with a custom hook called useProgram. I need that when the user logins that the app will fetch data from firestore and display the user's workout program, how can I do this? Keeping in mind that the useProgram hook is also used throughout the app to edit and update one's workout program.
App.tsx
import React, { useContext, useEffect, useState } from "react";
import { BrowserRouter as Router } from "react-router-dom";
import AppRouter from "./Router";
import FirebaseApp from "./firebase";
import SetProgram from "./context/program";
import { useProgram } from "./components/hooks/useProgram";
import firebaseApp from "./firebase/firebase";
import { useAuthState } from "react-firebase-hooks/auth";
function App() {
const program = useProgram();
const day = useDay();
const [user, loading, error] = useAuthState(firebaseApp.auth);
return (
<div className="App">
<SetProgram.Provider value={program}>
<Router>
<AppRouter />
</Router>
</SetProgram.Provider>
</div>
);
}
export default App;
firebase.ts
import firebase from "firebase/app";
import "firebase/auth";
import "firebase/firestore";
import firebaseConfig from "./config";
class Firebase {
auth: firebase.auth.Auth;
user: firebase.User | null | undefined;
db: firebase.firestore.Firestore;
userProgram: {} | undefined;
constructor() {
firebase.initializeApp(firebaseConfig);
this.auth = firebase.auth();
this.db = firebase.firestore();
}
async register() {
if (this.user) {
this.db.collection("users").doc(this.user.uid).set({
name: this.user.displayName,
email: this.user.email,
userId: this.user.uid,
program: {},
});
}
}
async getResults() {
return await this.auth.getRedirectResult().then((results) => {
console.log("results.user", results.user);
if (!results.additionalUserInfo?.isNewUser) {
this.getProgram();
} else {
this.register();
}
});
}
async login(
user: firebase.User | null | undefined,
loading: boolean,
error: firebase.auth.Error | undefined
) {
const provider = new firebase.auth.GoogleAuthProvider();
return await this.auth
.signInWithRedirect(provider)
.then(() => this.getResults());
}
async logout() {
return await this.auth.signOut().then(() => console.log("logged out"));
}
async updateProgram(user: firebase.User, program: {}) {
if (this.userProgram !== program) {
firebaseApp.db
.collection("users")
.doc(user.uid)
.update({
program: program,
})
.then(() => console.log("Program updated successfully!"))
.catch((error: any) => console.error("Error updating program:", error));
} else {
console.log("No changes to the program!");
}
}
async getProgram() {
firebaseApp.db
.collection("users")
.doc(this.user?.uid)
.get()
.then((doc) => {
console.log("hello");
if (doc.exists) {
this.userProgram = doc.data()?.program;
console.log("this.userProgram", this.userProgram);
} else {
console.log("doc.data()", doc.data());
}
});
}
}
const firebaseApp = new Firebase();
export default firebaseApp;
programContext.tsx
import React from "react";
import Program, { muscleGroup, DefaultProgram } from "../interfaces/program";
export interface ProgramContextInt {
program: Program | undefined;
days: Array<[string, muscleGroup]> | undefined;
setProgram: (p: Program) => void;
}
export const DefaultProgramContext: ProgramContextInt = {
program: undefined,
days: undefined,
setProgram: (p: Program): void => {},
};
const ProgramContext = React.createContext<ProgramContextInt>(
DefaultProgramContext
);
export default ProgramContext;
useProgram.tsx
import React from "react";
import {
ProgramContextInt,
DefaultProgramContext,
} from "../../context/program";
import Program, { muscleGroup } from "../../interfaces/program";
import { useAuthState } from "react-firebase-hooks/auth";
import firebaseApp from "../../firebase";
export const useProgram = (): ProgramContextInt => {
const [user] = useAuthState(firebaseApp.auth);
const [program, setEditedProgram] = React.useState<Program | undefined>();
const [days, setProgramDays] = React.useState<
[string, muscleGroup][] | undefined
>(program && Object.entries(program));
const setProgram = React.useCallback(
(program: Program): void => {
firebaseApp.updateProgram(user, program);
setEditedProgram(program);
setProgramDays(Object.entries(program));
},
[user]
);
return {
program,
days,
setProgram,
};
};
There are two ways to handle this in my opinion:
Update the ProgramContext to make sure that the user is logged in
Wrap the App or any other entry point from whence you need to make sure that the user is logged in, in a separate UserContextProvider
Let's talk about the latter method, where we can wrap in a separate context called UserContext. Firebase provides us a listener called onAuthStateChanged, which we can make use of in our context, like so:
import { createContext, useEffect, useState } from "react";
import fb from "services/firebase"; // you need to define this yourself. It's just getting the firebase instance. that's all
import fbHelper from "services/firebase/helpers"; // update path based on your project organization
type FirestoreDocSnapshot = firebase.default.firestore.DocumentSnapshot<firebase.default.firestore.DocumentData>;
const UserContext = createContext({ user: null, loading: true });
const UserContextProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const userContext = { user, loading };
const updateUser = (snapShot: FirestoreDocSnapshot) => {
setUser({
id: snapShot.id,
...snapShot.data,
});
};
const authStateListener = async (authUser: firebase.default.User) => {
try {
if (!authUser) {
setUser(authUser);
return;
}
const fbUserRef = await fbHelper.findOrCreateFirestoreUser(authUser)
if ("error" in fbUserRef) throw new Error(fbUserRef?.error);
(fbUserRef as FirestoreUserRef).onSnapshot(updateUser)
} catch (error) {
throw error;
} finally {
setLoading(false);
}
};
useEffect(() => {
const unSubscribeAuthStateListener = fb.auth.onAuthStateChanged(authStateListener);
return () => unSubscribeAuthStateListener();
}, [])
return (
<UserContext.Provider value={userContext}>
{children}
</UserContext.Provider>
)
};
export default UserContextProvider;
Where the helper can be something like this:
export type FirestoreUserRef = firebase.default.firestore.DocumentReference<firebase.default.firestore.DocumentData>
const findOrCreateFirestoreUser = async (authUser: firebase.default.User, additionalData = {}): Promise<FirestoreUserRef | { error?: string }> => {
try {
if (!authUser) return { error: 'authUser is missing!' };
const user = fb.firestore.doc(`users/${authUser.uid}`); // update this logic according to your schema
const snapShot = await user.get();
if (snapShot.exists) return user;
const { email } = authUser;
await user.set({
email,
...additionalData
});
return user;
} catch (error) {
throw error;
}
};
Then wrap your other context which provides firestore data, within this UserContextProvider. Thus whenever you login or logout, this particular listener be invoked.

Context evaluating as undefined in React Native app

I'm attempting to create an Auth context file which, upon app load, checks if a user is signed in.
To do this, I'm using a 'helper' function which allows me to import the initialisation of the context and just build upon that with additional functions which authorise a user.
However, upon every app load, the Context is returning as 'undefined', and it says 'evaluating _useContext.trySignIn'.
For reference, here is my Context file:
import createDataContext from './createDataContext';
import { AsyncStorage } from 'react-native';
import { navigate } from '../navigationRef';
import { Magic } from '#magic-sdk/react-native';
const m = new Magic('API_key');
const authReducer = (state, reducer) => {
switch (action.type) {
default:
return state;
}
};
const trySignIn = dispatch => async () => {
const isLoggedIn = await m.user.isLoggedIn();
if (isLoggedIn === true) {
navigate('Dashboard');
} else {
navigate('loginFlow');
}
};
export const { Provider, Context } = createDataContext (
authReducer,
{ trySignIn },
{ isLoggedIn: null }
);
Here is my 'createDataContext' file:
import React, { useReducer } from 'react';
export default (reducer, actions, defaultValue) => {
const Context = React.createContext();
const Provider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, defaultValue);
const boundActions = {};
for (let key in actions) {
boundActions[key] = actions[key].dispatch;
}
return (
<Context.Provider value={{ state, ...boundActions }}>
{children}
</Context.Provider>
)
};
return { Context, Provider }
};
Here is my navigation file:
import { NavigationActions } from 'react-navigation';
let navigator;
export const setNavigator = (nav) => {
navigation = nav;
};
export const navigate = (routeName, params) => {
navigator.dispatch(
NavigationActions.navigate({
routeName, params
})
);
};
And finally, here is my component attempting to use my context:
import React, { useEffect, useContext } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { Context } from '../context/AuthContext';
const LoadingScreen = () => {
const { trySignIn } = useContext(Context);
useEffect(() => {
trySignIn();
}, [])
return (
<View style={styles.mainView}>
<ActivityIndicator style={styles.indicator} />
</View>
)
}
Can anyone see why my context would be returning as 'undefined' in my component?

MobX and Next.js (Fast Refresh)

I can't seem to find a way to set initial checkbox state from MobX store after a fast refresh from Next.js. As soon as i refresh the page, it renders with the state i set before.
For example: i check the checkbox (which has the checked/onChange routed to MobX) -> Page refresh -> The input persists to be checked, while the state is set to false.
I've tried all the other ways to pass the observer HOC (Observer, useObserver), disabling hydration, reworking store, but to no avail.
Here's the code:
store/ThemeStore.ts
import { makeAutoObservable } from 'mobx';
export type ThemeHydration = {
darkTheme: boolean;
};
class ThemeStore {
darkTheme = false;
constructor() {
makeAutoObservable(this);
}
setDarkTheme(value: boolean) {
this.darkTheme = value;
}
hydrate(data?: ThemeHydration) {
if (data) {
this.darkTheme = data.darkTheme;
}
}
}
export default ThemeStore;
pages/index.tsx
import React, { useEffect } from "react";
import { reaction } from "mobx";
import styles from "#/styles/homepage.module.scss";
import { observer } from "mobx-react";
import { useStore } from "#/stores";
const HomePage = observer(function () {
const { themeStore } = useStore();
useEffect(() => {
const re = reaction(
() => themeStore.darkTheme,
(value) => {
const body = document.body;
if (value) {
body.classList.remove("theme-light");
body.classList.add("theme-dark");
} else {
body.classList.remove("theme-dark");
body.classList.add("theme-light");
}
},
{ fireImmediately: true }
);
return () => {
re();
};
}, []);
return (
<div className={styles.container}>
<main className={styles.main}>
<input
type="checkbox"
defaultChecked={themeStore.darkTheme}
onChange={(e) => {
themeStore.setDarkTheme(e.target.checked);
}}
/>
</main>
</div>
);
});
export default HomePage;
stores/index.tsx
import React, { ReactNode, createContext, useContext } from "react";
import { enableStaticRendering } from "mobx-react";
import RootStore, { RootStoreHydration } from "./RootStore";
enableStaticRendering(typeof window === "undefined");
export let rootStore = new RootStore();
export const StoreContext = createContext<RootStore | undefined>(undefined);
export const useStore = () => {
const context = useContext(StoreContext);
if (context === undefined) {
throw new Error("useRootStore must be used within RootStoreProvider");
}
return context;
};
function initializeStore(initialData?: RootStoreHydration): RootStore {
const _store = rootStore ?? new RootStore();
if (initialData) {
_store.hydrate(initialData);
}
// For SSG and SSR always create a new store
if (typeof window === "undefined") return _store;
// Create the store once in the client
if (!rootStore) rootStore = _store;
return _store;
}
export function RootStoreProvider({
children,
hydrationData,
}: {
children: ReactNode;
hydrationData?: RootStoreHydration;
}) {
const store = initializeStore(hydrationData);
return (
<StoreContext.Provider value={store}>{children}</StoreContext.Provider>
);
}
Thanks in advance!

Resources