Next.Js: Cannot update a component (`AuthContextProvider`) while rendering a different component (`Login`)

Viewed 33

I have a simple next.js app, that allows a user to login via a login-page. The login is done via a graphql-api. I'm using the react context-API and after the user has logged in succesfully I'm updating the context. Afterwards I would like to redirect the user to a dashboard-page. It actually works as intended, however always (and only) on the second login (= login, logout, login again) I get the following Error in my console:

enter image description here

I understand the error (or warning?), but I don't know why it occurs or what I'm doing wrong. Any suggestion to point me in the right direction is much appreciated.

Here's my code:

auth-context.ts

import { createContext, useEffect, useState } from 'react';

//type-definitions removed for better readability

const AuthContext = createContext<AuthContext>({
  isAuthenticated: false,
  isAdmin: false,
  userId: '',
  loginSuccessHandler: () => {},
  logoutHandler: () => {},
});

const AuthContextProvider = ({ children }: AuthContextProviderProps) => {
  const [authData, setAuthData] = useState<AuthData>({
    isAuthenticated: false,
    isAdmin: false,
    userId: null,
  });

  useEffect(() => {
    const storedIsAuthenticated = localStorage.getItem('isAuthenticated');
    const storedUserId = localStorage.getItem('userId');
    const storedRole = localStorage.getItem('role');

    if (storedIsAuthenticated === '1') {
      setAuthData({
        isAuthenticated: true,
        userId: storedUserId,
        isAdmin: storedRole === 'ADMIN',
      });
    }
  }, []);

  const loginSuccessHandler = (isAuthenticated: boolean, userId: string, role: string) => {
    localStorage.setItem('isAuthenticated', '1');
    localStorage.setItem('userId', userId);
    localStorage.setItem('role', role);

    setAuthData({
      isAuthenticated: isAuthenticated,
      userId: userId,
      isAdmin: role === 'ADMIN',
    });
  };

  const logoutHandler = () => {
    localStorage.removeItem('isAuthenticated');
    localStorage.removeItem('userId');
    localStorage.removeItem('role');
    setAuthData({
      isAuthenticated: false,
      userId: null,
      isAdmin: false,
    });
  };

  return (
    <AuthContext.Provider
      value={{
        isAuthenticated: authData.isAuthenticated,
        isAdmin: authData.isAdmin,
        userId: authData.userId,
        loginSuccessHandler,
        logoutHandler,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
};

export { AuthContext, AuthContextProvider };

pages/login.tsx

import { gql, useLazyQuery } from '@apollo/client';
import { useRouter } from 'next/router';
import { SyntheticEvent, useContext, useEffect, useRef } from 'react';
import TextInput from '../components/Input/TextInput';
import { AuthContext } from '../contexts/auth-context';
import type { NextPage } from 'next';

const Login: NextPage = () => {
  const emailInputRef = useRef<HTMLInputElement>(null);
  const passwordInputRef = useRef<HTMLInputElement>(null);
  const authCtx = useContext(AuthContext);
  const router = useRouter();

  const LOGIN_QUERY = gql`
    query LoginQuery($email: String!, $password: String!) {
      login(email: $email, password: $password) {
        userId
        token
        role
      }
    }
  `;

  const [login, { loading, error }] = useLazyQuery(LOGIN_QUERY);

  const submitButtonHandler = (event: SyntheticEvent) => {
    event.preventDefault();
    const email = emailInputRef?.current?.value || null;
    const password = passwordInputRef?.current?.value || null;
    
    login({
      variables: { email, password },
      onCompleted(data) {
        authCtx.loginSuccessHandler(true, data.login.userId, data.login.role);
      },
      onError(error) {
        authCtx.logoutHandler();
      },
    });
  };

  /* 
  The following Effect leads to the warning/error, when I remove it, the error disappears. 
  However, that's not what I want, I want it to work like this 
  */
  useEffect(() => {
    authCtx.isAuthenticated
      ? authCtx.isAdmin
        ? router.push('/admin')
        : router.push('/my-account')
      : router.push('/login');
  }, [authCtx.isAuthenticated, authCtx.isAdmin]);

  return (
    <div>
      <form>
        <h1>Login</h1>

        {loading && <p>Loading</p>}
        {error && <p>Error: {error.message}</p>}

        <TextInput type="email" id="email" ref={emailInputRef} />
        <TextInput type="password" id="password" ref={passwordInputRef} />
        <button onClick={submitButtonHandler} >
          Submit
        </button>
      </form>
    </div>
  );
};

export default Login;

In _app.tsx I use my AuthContextProvider like this:

import '../styles/globals.scss';
import Layout from '../components/Layout/Layout';
import type { AppProps } from 'next/app';
import { AuthContextProvider } from '../contexts/auth-context';
import { ApolloProvider } from '@apollo/client';
import apolloClient from '../lib/apollo-client';

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider client={apolloClient}>
      <AuthContextProvider>
        <Layout>
          <Component {...pageProps} />
        </Layout>
      </AuthContextProvider>
    </ApolloProvider>
  );
}

export default MyApp;

1 Answers

Thanks to @Anthony Ma's comment, I found that the onCompleted-Handler in login.tsx seemed to be the issue here. It updates the authContext while still being in the rendering-process of the login-component.

I changed my login.tsx file to add the data-response from my graphql-API to a state-object and then use an effect with that state-object as dependency (Find -> changed comments in the code below to see all changes).

Updated login.tsx:

import { gql, useLazyQuery } from '@apollo/client';
import { useRouter } from 'next/router';
/* 
-> changed: add useState to the list of imports
*/
import { SyntheticEvent, useContext, useEffect, useRef, useState } from 'react';
import TextInput from '../components/Input/TextInput';
import { AuthContext } from '../contexts/auth-context';
import type { NextPage } from 'next';

//type definition "AuthData" goes here

const Login: NextPage = () => {
  const emailInputRef = useRef<HTMLInputElement>(null);
  const passwordInputRef = useRef<HTMLInputElement>(null);
  const authCtx = useContext(AuthContext);
  const router = useRouter();

  /* 
  -> changed: Add authData State
  */
  const [authData, setAuthData] = useState<AuthData>({
    isAuthenticated: false,
    userId: '',
    role: '',
    token: '',
  });

  const LOGIN_QUERY = gql`
    query LoginQuery($email: String!, $password: String!) {
      login(email: $email, password: $password) {
        userId
        token
        role
      }
    }
  `;

  const [login, { loading, error }] = useLazyQuery(LOGIN_QUERY);

  const submitButtonHandler = (event: SyntheticEvent) => {
    event.preventDefault();
    const email = emailInputRef?.current?.value || null;
    const password = passwordInputRef?.current?.value || null;
    
    login({
      variables: { email, password },
      /*
      -> changed: Important: Set fetchPolicy to 'network-only' to prevent
         caching of the response (otherwise the response wont change on 
         every login request and therefore the state wont change resulting 
         in the effect (see below) not being triggered.
      */
      fetchPolicy: 'network-only', 
      onCompleted(data) {
        /* 
        -> changed: add response data to the new state object "authData"
        */
        setAuthData({
          isAuthenticated: true,
          userId: data.login.userId,
          role: data.login.role,
          token: data.login.token,
        });
      },
      onError(error) {
        authCtx.logoutHandler();
      },
    });
  };

  /*
  -> changed: added this new effect with the authData state object as
     dependency
  */
  useEffect(() => {
    authCtx.loginSuccessHandler({
      isAuthenticated: authData.isAuthenticated,
      userId: authData.userId,
      role: authData.role,
      token: authData.token,
    });
  }, [authData]);

  useEffect(() => {
    authCtx.isAuthenticated
      ? authCtx.isAdmin
        ? router.push('/admin')
        : router.push('/my-account')
      : router.push('/login');
  }, [authCtx.isAuthenticated, authCtx.isAdmin]);

  return (
    <div>
      <form>
        <h1>Login</h1>

        {loading && <p>Loading</p>}
        {error && <p>Error: {error.message}</p>}

        <TextInput type="email" id="email" ref={emailInputRef} />
        <TextInput type="password" id="password" ref={passwordInputRef} />
        <button onClick={submitButtonHandler} >
          Submit
        </button>
      </form>
    </div>
  );
};

export default Login;

Related