React context provider updates state after context consumer renders - javascript

I am trying to implement Protected Routes in my app. I am using cookie-based session authentication.
The issue is: Whenever I try to access a protected page for the first time, the RequireAuth component has the isAuthenticated value as false and hence it navigates to /.
From the console logs, I can see Inside require auth. before Inside provide auth..
Questions:
Is using useEffect in the context provider the right way to set the auth state?
How do I make sure that the context provider state is set before accessing the context in the consumer RequireAuth?
I have a context provider ProvideAuth which makes an API call to check if the user is already authenticated.
const authContext = createContext();
export function ProvideAuth({ children }) {
const navigate = useNavigate();
const location = useLocation();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [userInfo, setUserInfo] = useState({});
const fetchData = async () => {
const isAuthenticated = await CheckAuthentication();
setIsAuthenticated(isAuthenticated);
if (isAuthenticated) {
const userInfo = await GetUserInfo();
setUserInfo(userInfo);
}
}
useEffect(() => {
console.log("Inside provide auth. " + isAuthenticated + " " + location.pathname);
fetchData();
}, []);
const value = {
isAuthenticated,
userInfo
};
return <authContext.Provider value={value}>{children}</authContext.Provider>;
}
Auth context consumer
export const useAuth = () => {
return useContext(authContext);
};
I use the context in a RequireAuth component to check if the user is already authenticated and redirect if not.
export default function RequireAuth({ children }) {
const { isAuthenticated, userInfo } = useAuth();
const location = useLocation();
useEffect(() => {
console.log("Inside require auth. " + isAuthenticated + " " + location.pathname);
}, []);
return isAuthenticated === true ?
(children ? children : <Outlet />) :
<Navigate to="/" replace state={{ from: location }} />;
}
The context provider is used in the App.js
return (
<ProvideAuth>
<div className='App'>
<Routes>
<Route exact path="/" element={<Home />} />
<Route path="/pricing" element={<Pricing />} />
<Route element={<RequireAuth /> }>
<Route path="/jobs" element={<Jobs />} >
<Route index element={<MyJobs />} />
<Route path="new" element={<NewJob />} />
<Route path=":jobId" element={<JobDetails />} />
<Route path=":jobId/stats" element={<JobStats />} />
</Route>
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</div>
</ProvideAuth>
);

That's because that useEffect in ProvideAuth is as any useEffect an asynchronous task, which means the component and its children may render before its callback gets executed.
A solution is to set up a loading state in ProvideAuth, called for example isCheckingAuth, set to true by default, and to false after you have done all the fetching. And you pass it down to RequireAuth, like so :
const authContext = createContext();
export function ProvideAuth({ children }) {
const navigate = useNavigate();
const location = useLocation();
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [userInfo, setUserInfo] = useState({});
const fetchData = async () => {
const isAuthenticated = await CheckAuthentication();
setIsAuthenticated(isAuthenticated);
if (isAuthenticated) {
const userInfo = await GetUserInfo();
setUserInfo(userInfo);
}
setIsCheckingAuth(false)
}
useEffect(() => {
console.log("Inside provide auth. " + isAuthenticated + " " + location.pathname);
fetchData();
}, []);
const value = {
isAuthenticated,
userInfo,
isCheckingAuth
};
return <authContext.Provider value={value}>{children}</authContext.Provider>;
}
You use that isCheckingAuth inRequireAuth to show a loader while the fetching is being done, this way:
export default function RequireAuth({ children }) {
const { isAuthenticated, userInfo, isCheckingAuth } = useAuth();
const location = useLocation();
useEffect(() => {
if(isCheckingAuth) return;
console.log("Inside require auth. " + isAuthenticated + " " + location.pathname);
}, [isCheckingAuth]);
if(isCheckingAuth) return <div>Loading...</div>
return isAuthenticated === true ?
(children ? children : <Outlet />) :
<Navigate to="/" replace state={{ from: location }} />;
}

What you can do is check, If the request is processed or not. If processing show loader if any error shows some error msg or redirect. If everything is fine load provider.
const authContext = createContext();
export function ProvideAuth({ children }) {
const [state, setState] = useState({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
});
useEffect(() => {
const fetchData = async () => {
try {
const isAuthenticated = await CheckAuthentication();
if (isAuthenticated) {
const user = await GetUserInfo();
setState((prev) => ({ ...prev, isAuthenticated, user }));
}
} catch (error) {
setState((prev) => ({ ...prev, error }));
} finally {
setState((prev) => ({ ...prev, isLoading: false }));
}
};
fetchData();
}, []);
if (state.isLoading) return <Loading />;
if (state.error) return <ErrorMessage error={state.error} />;
return <authContext.Provider value={state}>{children}</authContext.Provider>;
}

Related

React - How to stay on the same page even if it was refreshed?

I'm using react-router for the link to the different pages. Everything works fine, however, once I'll refresh the page, it'll go to the login page for a moment and it'll go back to the homepage. It was even worse if I'll go to the admin page, refreshing the page will direct the user to the login page, however, the user is still logged in and only displays the login page. I'm also using Firebase Firestore and firebase authentication.
app.js
const App = (props) => {
const { setCurrentUser, currentUser } = props;
const admin = checkUserAdmin(currentUser);
console.log(admin);
useEffect(() => {
const authListener = auth.onAuthStateChanged(async (userAuth) => {
if (userAuth) {
const userRef = await handleUserProfile(userAuth);
userRef.onSnapshot((snapshot) => {
setCurrentUser({
id: snapshot.id,
...snapshot.data(),
});
});
}
setCurrentUser(userAuth);
});
return () => {
authListener();
};
}, []);
return (
<div className="App">
<Switch>
<Route
exact
path="/login"
render={() => (
<MainLayout>
<LoginPage />
</MainLayout>
)}
/>
<Route
exact
path="/profile"
render={() => (
<WithAuth>
<MainLayout>
<ProfilePage />
</MainLayout>
</WithAuth>
)}
/>
<Route
exact
path="/admin"
render={() => (
<WithAdmin>
<AdminHome />
</WithAdmin>
)}
/>
</Switch>
</div>
);
};
const mapStateToProps = ({ user }) => ({
currentUser: user.currentUser,
});
const mapDispatchToProps = (dispatch) => ({
setCurrentUser: (user) => dispatch(setCurrentUser(user)),
});
export default connect(mapStateToProps, mapDispatchToProps)(App);
withAuth - restricting the users for the pages. If currentUser is a guest user, it directs the user to the login page.
import { useAuth } from "./../custom-hooks";
import { withRouter } from "react-router-dom";
const WithAuth = (props) => useAuth(props) && props.children;
export default withRouter(WithAuth);
useAuth - restricting the users for the pages. If currentUser is a guest user, it directs the user to the login page.
const mapState = ({ user }) => ({
currentUser: user.currentUser,
});
const useAuth = (props) => {
const { currentUser } = useSelector(mapState);
useEffect(() => {
if (!currentUser) {
props.history.push("/login");
}
}, [currentUser]);
return currentUser;
};
export default useAuth;
withAdmin - pages only accessible to the admin
import { useAdmin } from "../../custom-hooks";
const WithAdmin = (props) => useAdmin(props) && props.children;
export default WithAdmin;
useAdmin - pages only accessible to the admin. If user is not an admin, it directs the user to the login page.
const mapState = ({ user }) => ({
currentUser: user.currentUser,
});
const useAdmin = (props) => {
const { currentUser } = useSelector(mapState);
const history = useHistory();
useEffect(() => {
if (!checkUserAdmin(currentUser)) {
history.push("/login");
}
}, [currentUser]);
return currentUser;
};
export default useAdmin;
Below is my index.js
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</React.StrictMode>,
document.getElementById("root")
);
Reducers:
userTypes:
const userTypes = {
SET_CURRENT_USER: "SET_CURRENT_USER",
};
export default userTypes;
userActions:
import userTypes from "./user.types";
export const setCurrentUser = (user) => ({
type: userTypes.SET_CURRENT_USER,
payload: user,
});
userReducer:
import userTypes from "./user.types";
const INITIAL_STATE = {
currentUser: null,
};
const userReducer = (state = INITIAL_STATE, action) => {
switch (action.type) {
case userTypes.SET_CURRENT_USER:
return {
...state,
currentUser: action.payload,
};
default:
return state;
}
};
export default userReducer;
rootReducer:
import { combineReducers } from "redux";
import userReducer from "./user/user.reducer";
export default combineReducers({
user: userReducer,
});
store.js
import { createStore, applyMiddleware } from "redux";
import logger from "redux-logger";
import rootReducer from "./rootReducer";
export const middlewares = [logger];
export const store = createStore(rootReducer, applyMiddleware(...middlewares));
export default store;
checkUserAdmin.js
export const checkUserAdmin = (currentUser) => {
if (!currentUser || !Array.isArray(currentUser.roles)) return false;
const { roles } = currentUser;
if (roles.includes("admin")) return true;
return false;
};
From the App.js, I console.log(currentUser) and this is what is shows:
I suggest adding an authPending state to your userReducer, initially true, and also set/cleared when the firestore logic is handing user changes.
userReducer & actions
const userTypes = {
SET_AUTH_PENDING: "SET_AUTH_PENDING",
SET_CURRENT_USER: "SET_CURRENT_USER",
};
const setAuthPending = pending => ({
type: userTypes.SET_AUTH_PENDING,
payload: pending,
});
const INITIAL_STATE = {
authPending: true,
currentUser: null,
};
const userReducer = (state = INITIAL_STATE, action) => {
switch (action.type) {
case userTypes.SET_CURRENT_USER:
return {
...state,
authPending: false
currentUser: action.payload,
};
case userTypes.SET_AUTH_PENDING:
return {
...state,
authPending: action.payload,
};
default:
return state;
}
};
app.js
const App = (props) => {
const {
setAuthPending, // <-- access action
setCurrentUser,
currentUser
} = props;
const admin = checkUserAdmin(currentUser);
console.log(admin);
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged(async (userAuth) => {
setAuthPending(true); // <-- start auth pending
if (userAuth) {
const userRef = await handleUserProfile(userAuth);
userRef.onSnapshot((snapshot) => {
setCurrentUser({ // <-- will clear auth pending
id: snapshot.id,
...snapshot.data(),
});
});
} else {
setCurrentUser(null); // <-- clear user data and pending
}
});
return () => {
unsubscribe();
};
}, []);
return (
<div className="App">
<Switch>
...
</Switch>
</div>
);
};
const mapStateToProps = ({ user }) => ({
currentUser: user.currentUser,
});
const mapDispatchToProps = {
setAuthPending, // <-- wrap action creator in call to dispatch
setCurrentUser,
};
Hooks & Wrappers
For these I suggest abstracting the logic into custom Route components.
const AuthRoute = props => {
const { authPending, currentUser } = useSelector(state => state.user);
if (authPending) {
return "Loading..."; // or maybe a loading spinner
};
return currentUser ? (
<Route {...props} />
) : (
<Redirect to="/login" />
);
};
const AdminRoute = props => {
const { authPending, currentUser } = useSelector(state => state.user);
if (authPending) {
return "Loading..."; // or maybe a loading spinner
};
return checkUserAdmin(currentUser) ? (
<Route {...props} />
) : (
<Redirect to="/login" />
);
};
Then the routes become
<Switch>
<Route
exact
path="/"
render={() => (
<MainLayout>
<Homepage />
</MainLayout>
)}
/>
<Route
exact
path="/login"
render={() => (
<MainLayout>
<LoginPage />
</MainLayout>
)}
/>
<AuthRoute
exact
path="/profile"
render={() => (
<MainLayout>
<ProfilePage />
</MainLayout>
)}
/>
<AdminRoute
exact
path="/admin"
component={AdminHome}
/>
</Switch>
After this, you may want to look into persisting your redux state into localStorage, and repopulating your redux state from localStorage when you are instantiating the store (the preloadedState parameter) object when your app is loading. You can manage yourself or look into something like redux-persist.
When a user login you can store some values about the user in localStorage,like username or a token or just a login ,
localStorage.setItem(IS_LOGIN, true);
After that you can use that in your userReducer, when you initiate state you can directly determine the user is login or not.
const INITIAL_STATE = {
isLogin: localStorage.IS_LOGIN
};
now you can determine a user is login or not before the page load. If you wanna push user to the login page you can use in useEffect
useEffect(() => {
if (!isLogin) {
props.history.push("/login");
}
}, [isLogin]);
return isLogin;
};
when your app first loaded there is no user information on the userReducer, because of that when page load you will be directing to the login page.

React Component wont render with given state from the parent component

I need to render a component that has a route using react router. the first component has a button that when clicked needs to render another component that has state passed in from the first component. All objects and strings from the first component show in the console.log of the child component but it wont set state when I use setProfile(p).
const Member = (props)=> {
const [user, setUser] = useState({});
const [profile, setProfile] = useState({});
// run effect when user state updates
useEffect(() => {
const doEffects = async () => {
try {
const pro = socialNetworkContract.members[0]
console.log(pro)
const p = await incidentsInstance.usersProfile(pro, { from: accounts[0] });
const a = await snInstance.getUsersPosts(pro, { from: accounts[0] });
console.log(a)
console.log(p)
setProfile(p)
} catch (e) {
console.error(e)
}
}
doEffects();
}, [profile, state]);
const socialNetworkContract = useSelector((state) => state.socialNetworkContract)
return (
<div class="container">
<a target="_blank">Name : {profile.name}</a>
{socialNetworkContract.posts.map((p, index) => {
return <tr key={index}>
{p.message}
</tr>})}
</div>
)
}
export default Member;
This is the parent component I want to redirect from
const getProfile = async (member) => {
const addr = dispatch({ type: 'ADD_MEMBER', response: member })
console.log(member)
}
const socialNetworkContract = useSelector((state) => state.socialNetworkContract)
return (
<div>
{socialNetworkContract.posts.map((p, index) => {
return <tr key={index}>
<button onClick={() => getProfile(p.publisher)}>Profile</button>
</tr>})}
</div>
)
}
export default withRouter(Posts);
I have this component working when I don't have a dynamic route that needs data passing in from the parent component It's redirecting from.
This is my routes.js file
const Routes = () => {
return (
<Switch>
<Route path="/posts" exact component={Posts} />
<Route path="/member" exact component={Member} />
<Redirect exact to="/" />
</Switch>
)
}
export default Routes
https://codesandbox.io/s/loving-pine-tuxxb

What is the best way to pass in props to a react router route?

I have a react component I need to render that takes one argument of a string when it is initialized. I need a button to click on that will redirect and render this new component with the string. It sends the string I want when it console.log(pro). everytinme I click on the button it goes to a blank screen and doesn't load.
My routes.js looks like
const Routes = (props) => {
return (
<Switch>
<Route exact path="/member" component={() => <Member user={props.state.member} />} />
<Route path="/posts" exact component={Posts} />
<Redirect exact to="/" />
</Switch>
)
}
export default Routes
The original component looks like this
const Posts = (props) => {
const dispatch = useDispatch();
const [postCount, setPostCount] = useState(0);
const [member, setMember] = useState({});
const getProfile = async (member) => {
const addr = dispatch({ type: 'ADD_MEMBER', response: member })
console.log(member)
props.history.push('/member'
);
console.log('----------- member------------') // console.log(addr)
return (
<Member user={member}><Member/>
);
}
return (
<div>
{socialNetworkContract.posts.map((p, index) => {
return <tr key={index}>
<button onClick={() => getProfile(p.publisher)}>Profile</button>
</tr>})}
</div>
)
}
export default withRouter(Posts);
The component I'm trying to render from the Posts component needs to be rendered with a string
const Member = (props)=> {
const [user, setUser] = useState({});
const { state } = props.location;
const [profile, setProfile] = useState({});
useEffect(() => {
const doEffects = async () => {
try {
const pro = socialNetworkContract.members[0]
console.log(pro)
const p = await incidentsInstance.usersProfile(pro, { from: accounts[0] });
setProfile(p)
} catch (e) {
console.error(e)
}
}
doEffects();
}, [profile]);
return (
<div class="container">
{profile.name}
</div>
)
}
export default Member;
You can pass an extra data to a route using state attribute with history.push
const getProfile = async (member) => {
const addr = dispatch({ type: 'ADD_MEMBER', response: member })
console.log(member)
props.history.push({
path: '/member',
state: { member }
});
}
Once you do that you can access it in the rendered route from location.state
import {
useLocation
} from "react-router-dom";
const Member = (props)=> {
const [user, setUser] = useState({});
const { state } = useLocation();
console.log(state.member);
const [profile, setProfile] = useState({});
...
}
export default Member;
Also you do not need to pass on anything while rendering the Route
const Routes = (props) => {
return (
<Switch>
<Route exact path="/member" component={Member} />
<Route path="/posts" exact component={Posts} />
<Redirect exact to="/" />
</Switch>
)
}
export default Routes

How do you wait for useAuth useEffect to return the current user state?

I have a bit of a problem implementing authentication for my React application. I followed this link to get the authentication going. Here's my App component:
function App() {
return (
<ProvideAuth>
<BrowserRouter>
<Header />
<Switch>
<PrivateRoute exact path="/">
<Dashboard />
</PrivateRoute>
<Route path="/login">
<Login />
</Route>
</Switch>
</BrowserRouter>
</ProvideAuth>
);
}
function PrivateRoute({ children, ...rest }) {
let auth = useAuth();
console.log("USER: ", auth.user);
return (
<Route
{...rest}
render={({ location }) =>
auth.user ? (
children
) : (
<Redirect
to={{
pathname: "/login",
state: { from: location }
}}
/>
)} />
)
}
export default App;
Login component:
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
let history = useHistory();
let location = useLocation();
let auth = useAuth();
let { from } = location.state || { from: { pathname: "/" } }
let login = (e) => {
auth.signin(email, password, () => {
history.replace(from);
});
};
return (
<div>
<input onChange={e => setEmail(e.target.value)} value={email} type="email" />
<input onChange={e => setPassword(e.target.value)} value={password} type="password" />
</div>
)
}
export default Login;
Finally use-auth.js:
const authContext = createContext();
export function ProvideAuth({ children }) {
const auth = useProvideAuth();
return <authContext.Provider value={auth}>{children}</authContext.Provider>;
};
export const useAuth = () => {
return useContext(authContext);
};
function useProvideAuth() {
const [user, setUser] = useState(null);
const signin = (email, password, callback) => {
axios.post(`${apiUrl}/sign_in`, {
'email': email,
'password': password
},
{
headers: {
'Content-Type': 'application/json'
}
}).then(res => {
const expiryDate = new Date(new Date().getTime() + 6 * 60 * 60 * 1000).toUTCString();
document.cookie = `access-token=${res.headers['access-token']}; path=/; expires=${expiryDate}; secure; samesite=lax`;
return res.data
})
.then(data => {
setUser(data.data);
callback();
})
.catch(e => {
setUser(null);
});
};
const signout = () => {
document.cookie = "access-token=; expires = Thu, 01 Jan 1970 00:00:00 GMT";
setUser(null);
}
useEffect(() => {
const cookies = getCookies();
if (cookies['access-token']) {
axios.get(`${apiUrl}/user_info`, {
headers: {
...cookies
}
}).then(res => {
return res.data;
})
.then(data => {
setUser(data);
})
.catch(e => {
setUser(null);
})
} else {
setUser(null);
}
}, []);
return {
user,
signin,
signout
}
}
function getCookies() {
let cookies = document.cookie.split(';');
let authTokens = {
'access-token': null
};
for (const cookie of cookies) {
let cookiePair = cookie.split('=');
if (authTokens.hasOwnProperty(cookiePair[0].trim().toLowerCase()))
authTokens[cookiePair[0].trim()] = decodeURIComponent(cookiePair[1]);
}
return authTokens;
}
and then the dashboard component is the homepage. Nothing interesting.
The problem is when a user is in fact logged in (the access-token cookie is set as well as other tokens), they're still routed to the login page because of the fact that calling the API which checks that these tokens are valid is asynchronous, so the user is set to null initially.
What am I missing here? how can I wait until the API response is returned without blocking the user interface? Should I save user state in the redux state or is there some other work around?
Thanks a lot!
Like Jonas Wilms suggested, I added a loading state variable in user-auth similar to user and set it to true before each request and false after the request is completed.
In my App component, I changed the PrivateRoute function to show a loading spinner as long as the user state is loading. When it's set to false, I check whether the user is logged in or not and show the Dashboard component or redirect to login page accordingly.
function PrivateRoute({ children, ...rest }) {
let auth = useAuth();
return (
<Route
{...rest}
render={({ location }) =>
auth.loading ?
<Loading /> :
auth.user ? (
children
) : (
<Redirect
to={{
pathname: "/login",
state: { from: location }
}}
/>
)} />
)
}

useContext causing unwanted re-render

I am having trouble with rerenders and memory leaks in my login form. The goal is to have a component that checks if the context's JWT is valid and if so redirects. However, when logging in and updating the context, the context causes a rerender when it should redirect anyways. What is the solution to this?
Edit: The issue seems to be that I am rerendering on authentication twice: once in Login and one in the SecuredRoute. Is there a more elegant solution?
useValidateToken.js
export default token => {
const [validateLoading, setLoading] = useState(true);
const [authenticated, setAuthenticated] = useState(false);
useEffect(() => {
fetch(`/validate_token`, {
method: "GET",
headers: { Authorization: "Bearer " + token }
})
.then(resp => {
if (resp.ok) setAuthenticated(true);
setLoading(false);
})
.catch(_ => setLoading(false));
}, [token]);
return { validateLoading, authenticated };
};
Login.js
function Login(props) {
const {token, setToken} = useContext(TokenContext)
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const { isLoading: validateLoading, authenticated } = useValidateToken(token);
const [shouldRedirect, setShouldRedirect] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isInvalid, setIsInvalid] = useState(false);
function login() {
setIsLoading(true);
fetch(`/login`, { ... })
.then(resp => resp.json())
.then(body => {
if (body.jwt) {
setToken(body.jwt);
setShouldRedirect(true);
} else {
setIsInvalid(true);
setTimeout(function () { setIsInvalid(false) }, 3000)
setIsLoading(false);
}
})
.catch(_ => setIsLoading(false));
}
return validateLoading ? (
// Skipped code
) : shouldRedirect === true || authenticated === true ? (
<Redirect to={props.location.state ? props.location.state.from.pathname : "/"} />
) : (
<div className="login">
// Skipped code
<LoginButton loading={isLoading} login={login} error={isInvalid} />
</div>
</div>
);
}
The Route is secured using a custom component. This is done to secure routes and redirect to Login if there is an invalid token.
App.js
// Skipped code
const [token, setToken] = useState(null);
const { authenticated } = useValidateToken(token)
//Skipped code
<SecuredRoute exact path="/add-issue/" component={AddIssue} authenticated={authenticated} />
function SecuredRoute({ component: Component, authenticated, ...rest }) {
return (
<Route
{...rest}
render={props =>
authenticated === true ? (
<Component {...props} {...rest} />
) : (
<Redirect to={{ pathname: "/login", state: { from: props.location } }} />
)
}
/>
);
}

Categories

Resources