React app keeps refreshing when changing i18next language in combination with authenticated routes

Viewed 44

I have a react-app with authentication (cookie based) with a login route and a profile route. The profile route is fetching the profile data and puts the data in a form. One of the fields is a language field. I'm using the useEffect hook to watch the language property and use i18n.changeLanguage() to change the language.

For some reason the page keeps refreshing when I add this code. It must be a combination of this code together with the code I'm using the check if the user is authenticated to access the route. When I comment out the protectedRoute function or the useEffect hook it's working but I obviously need both.

A small breakdown of the protectedRoute function and authContext.

The routes are wrapped in an AuthProvider

const App = () => {
    return (
        <BrowserRouter>
            <AuthProvider>
                <Router />
            </AuthProvider>
        </BrowserRouter>
    );
};

Inside the AuthProvider I have a user and isAuthenticated state. Both starting with a value of null. On mount a call with or without a cookie is done to the backend to get the user info. If a user object is returned with an id the isAuthenticated state is set to true.

const AuthProvider = ({ children }) => {
  const [isAuthenticated, setIsAuthenticated] = useState(null);
  const [user, setUser] = useState(null);

  useEffect(() => {
    getUserInfo();
  }, []);

  const getUserInfo = async () => {
    try {
      const { data } = await authService.me();

      setIsAuthenticated(true);
      setUser(data);
    } catch (error) {
      setIsAuthenticated(false);
      setUser({});
    }
  };

  const setAuthInfo = (user) => {
    setIsAuthenticated(!!(user && user.id));
    setUser(user);
  };

...

As long as isAuthenticated is null a loading state is rendered instead of a route.

  if (authContext.isAuthenticated === null) {
    return (
      <div>
        <span>Loading...</span>
      </div>
    );
  }

  const ProtectedRoutes = () => {
    return authContext.isAuthenticated ? (
      <Outlet />
    ) : (
      <Navigate to="/login" replace />
    );
  };

  return (
    <Routes>
      <Route path="/login" element={<Login />} />

      <Route element={<ProtectedRoutes />}>
        <Route path="/" element={<Navigate to="/profile" replace />} />
        <Route path="/profile" element={<ProfileOverview />} />
      </Route>
    </Routes>
  );

The profile page can be accessed when the isAuthenticated state is true. Inside the profile page the user profile information is fetched and with a reset set into the form state. This will trigger the useEffect hook watching the formData.language property which will set the language to the user's language. This leads to a continuous refresh and I can't find the reason why or what I'm doing wrong.

const Profile = () => {
  const { i18n } = useTranslation();

  const { formData, reset, handleSubmit, handleChange } = useForm({});

  useEffect(() => {
    console.log("Get profile data");
    getProfileInfo();
  }, []);

  useEffect(() => {
    i18n.changeLanguage(formData.language);
  }, [formData.language]);

  const getProfileInfo = async () => {
    const { data } = await profileService.getProfileInfo();
    reset(data);
  };

  const submit = (values) => {
    console.log("submit");
  };

...

Codesandbox demo over here. I have put a console.log inside the useEffect on the profile page so you can see that it keeps refreshing. Login can be done without credentials. All fetches are done with a setTimeout to fake real calls.

1 Answers

Taken from the Codesandbox demo and modified within Profile.js. The idea is to block additional requests to i18n.changeLanguage until the previous request has finished and the language was truly updated.

  // Use a state variable to verify we aren't already loading
  const [isLoadingLanguage, setLoadingLanguage] = useState(false);
  useEffect(() => {
    // Verify we aren't loading and are actually changing the language
    if (!isLoadingLanguage && formData.language !== language) {
      const load = async () => {
        // Set the state as loading so we don't perform additional requests
        setLoadingLanguage(true);

        // Since this method returns a promise, and useEffect does not allow async/await easily,
        // make and call an async method so we can await changeLanguage
        await i18n.changeLanguage(formData.language);

        // Originally, we wanted to set this again to update when the formData language updated, but we'll do in submit
        // setLoadingLanguage(false);
      };
      load();
    }
  }, [i18n, formData.language]);

I would note, getting this to work in the sandbox required removing the line to i18n.changeLanguage so perpetual renders would not keep submitting requests. Then, adding back the useEffect above loaded the form and provided single submissions.

Edit: To prevent i18n.changeLanguage from rendering the component again we can do a few things.

  • Remove the setLoadingLanguage(false) from the useEffect so we don't trigger a language change until we submit

  • Add i18n.changeLanguage to the submit method AND localStorage.setItem("formData", JSON.stringify(formData) to retain the state when we do submit

  const submit = (values) => {
    localStorage.setItem("formData", JSON.stringify(formData));
    i18n.changeLanguage(formData.language);
    console.log("submit");
  };
  • Retrieve any localStorage item for "formData" in place of the getProfileInfo
export default {
  getProfileInfo() {
    console.log({ localStorage });
    const profileData =
      localStorage.getItem("formData") !== null
        ? JSON.parse(localStorage.getItem("formData"))
        : {
            first_name: "John",
            last_name: "Doe",
            language: "nl"
          };

    const response = {
      data: profileData
    };

    return new Promise((resolve) => {
      setTimeout(() => resolve(response), 100);
    });
  }
};

Related